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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-13 08:13:01 +03:00
committed by GitHub
120 changed files with 3798 additions and 1409 deletions

View File

@@ -12,6 +12,7 @@ import {
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
ArrowRight,
@@ -125,6 +126,7 @@ export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
// Per-tab filter controls (empty/null = "all").
@@ -158,6 +160,8 @@ export default function BookingRequestsPage() {
// React Query cache key per kind tab.
tab: kindTab,
bookingType: kindTab,
// Server-side free-text search (booking ref, customer, contract ref).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
@@ -178,6 +182,7 @@ export default function BookingRequestsPage() {
pagination.pageIndex,
pagination.pageSize,
kindTab,
debouncedQuery,
statusFilter,
directionFilter,
freightTypeFilter,
@@ -245,17 +250,12 @@ export default function BookingRequestsPage() {
resetPage();
}, [resetPage]);
const rows = useMemo(() => {
const items = (data?.items ?? []).map(toBookingListRow);
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(
(b) =>
b.reference.toLowerCase().includes(q) ||
b.customerLabel.toLowerCase().includes(q) ||
(b.contractReference?.toLowerCase().includes(q) ?? false),
);
}, [data?.items, query]);
// Search is applied server-side (via the `search` filter param) — no
// client-side filtering here.
const rows = useMemo(
() => (data?.items ?? []).map(toBookingListRow),
[data?.items],
);
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
@@ -496,7 +496,10 @@ export default function BookingRequestsPage() {
placeholder="Search booking, contract or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
@@ -504,7 +507,10 @@ export default function BookingRequestsPage() {
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>

View File

@@ -0,0 +1,309 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Badge,
Group,
Paper,
SegmentedControl,
Tabs,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking";
/**
* Operations "Clearance Documents" hub — the worklist for clearance-document
* review on contracts WITHOUT customs clearing (self-clearance / Path A):
*
* - Contracts tab: contracts whose clearance runs at contract level; rows open
* the contract clearance detail where Operations approves + finalizes.
* - General tab: booking instances under GENERAL non-customs contracts (those
* clear per booking); rows open the booking clearance review page.
*
* The hub only lists — all review/approve/finalize actions live on the
* existing detail pages it links to.
*/
type HubTab = "contracts" | "general";
type QueueTab = "queue" | "history";
const PAGE_SIZE = 10;
/** Booking statuses that mean "docs awaiting review" / "review finished". */
const BOOKING_QUEUE_STATUS = "DOCUMENTS_UNDER_REVIEW";
const BOOKING_HISTORY_STATUS = "CLEARANCE_READY";
function formatDate(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleDateString(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
});
}
function statusLabel(status?: string | null): string {
return (status ?? "—").replaceAll("_", " ");
}
function StatusBadge({ status }: { status?: string | null }) {
const done =
status === "CLEARANCE_READY" ||
status === "CLEARANCE_READY_FOR_BOOKING" ||
status === "ACTIVE" ||
status === "CONTRACT_ACTIVE" ||
status === "FULLY_EXECUTED";
return (
<Badge variant="light" color={done ? "edr-green" : "yellow"} radius="sm">
{statusLabel(status)}
</Badge>
);
}
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const [hubTab, setHubTab] = useState<HubTab>("contracts");
const [queueTab, setQueueTab] = useState<QueueTab>("queue");
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const search = debouncedQuery.trim() || undefined;
const contractsPager = usePagination({ pageSize: PAGE_SIZE });
const generalPager = usePagination({ pageSize: PAGE_SIZE });
// Any search / queue-history / tab switch restarts both lists from page 1.
useEffect(() => {
contractsPager.setPagination((p) => ({ ...p, pageIndex: 0 }));
generalPager.setPagination((p) => ({ ...p, pageIndex: 0 }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQuery, queueTab, hubTab]);
const isHistory = queueTab === "history";
const contractsQuery = useQuery({
queryKey: [
"clearance-documents",
"contracts",
queueTab,
contractsPager.pagination.pageIndex,
search,
],
queryFn: () => {
const filter = {
page: contractsPager.pagination.pageIndex + 1,
pageSize: PAGE_SIZE,
search,
};
return isHistory
? contractsService.getOpsClearanceHistory(filter)
: contractsService.getOpsClearanceQueue(filter);
},
enabled: hubTab === "contracts",
placeholderData: keepPreviousData,
});
const generalQuery = useQuery({
queryKey: [
"clearance-documents",
"general",
queueTab,
generalPager.pagination.pageIndex,
search,
],
queryFn: () =>
bookingsService.list({
status: isHistory ? BOOKING_HISTORY_STATUS : BOOKING_QUEUE_STATUS,
bookingType: "GENERAL_CONTRACT",
customsClearingEnabled: "false",
page: generalPager.pagination.pageIndex + 1,
pageSize: PAGE_SIZE,
search,
}),
enabled: hubTab === "general",
placeholderData: keepPreviousData,
});
const contractColumns = useMemo(
(): ColumnDef<Freight.IContract, unknown>[] => [
{
header: "Reference",
accessorKey: "reference",
},
{
header: "Customer",
cell: ({ row }) => row.original.company?.name ?? "—",
},
{
header: "Kind",
cell: ({ row }) => statusLabel(row.original.contractKind),
},
{
header: "Direction",
cell: ({ row }) => statusLabel(row.original.tradeDirection),
},
{
header: "Freight",
cell: ({ row }) => statusLabel(row.original.freightType),
},
{
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
header: "Created",
cell: ({ row }) => formatDate(row.original.createdAt),
},
],
[],
);
const bookingColumns = useMemo(
(): ColumnDef<BookingDetail, unknown>[] => [
{
header: "Reference",
accessorKey: "reference",
},
{
header: "Customer",
cell: ({ row }) =>
row.original.isGovernment
? (row.original.governmentInstitution ?? "Government")
: (row.original.company?.name ?? "—"),
},
{
header: "Contract",
cell: ({ row }) => row.original.contractReference ?? "—",
},
{
header: "Direction",
cell: ({ row }) => statusLabel(row.original.tradeDirection),
},
{
header: "Freight",
cell: ({ row }) => statusLabel(row.original.freightType),
},
{
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
],
[],
);
const activeQuery = hubTab === "contracts" ? contractsQuery : generalQuery;
const total = activeQuery.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const tableStatus = activeQuery.isLoading
? "loading"
: activeQuery.isError
? "error"
: "success";
return (
<PageContainer>
<PageHeader
title="Clearance Documents"
subtitle="Operations review of customer clearance documents for contracts without customs clearing — contract-level (one-time) and per-booking (general)."
/>
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<Tabs
value={hubTab}
onChange={(v) => setHubTab((v as HubTab) ?? "contracts")}
>
<Tabs.List>
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
<Tabs.Tab value="general">General</Tabs.Tab>
</Tabs.List>
</Tabs>
<Group gap="sm">
<SegmentedControl
value={queueTab}
onChange={(v) => setQueueTab(v as QueueTab)}
data={[
{ value: "queue", label: "Queue" },
{ value: "history", label: "History" },
]}
size="xs"
/>
<TextInput
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
placeholder={
hubTab === "contracts"
? "Search reference or customer…"
: "Search booking, customer or contract…"
}
leftSection={<Search size={14} />}
w={260}
/>
</Group>
</Group>
{hubTab === "contracts" ? (
<DataTable<Freight.IContract, unknown>
columns={contractColumns}
data={contractsQuery.data?.items ?? []}
status={tableStatus}
onRowClick={(row) =>
navigate(`/dashboard/contracts/clearance/${row.id}`)
}
pagination={{
pageIndex: contractsPager.pagination.pageIndex,
pageSize: PAGE_SIZE,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination: contractsPager.pagination },
onPaginationChange: contractsPager.setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
) : (
<DataTable<BookingDetail, unknown>
columns={bookingColumns}
data={generalQuery.data?.items ?? []}
status={tableStatus}
onRowClick={(row) => navigate(`/dashboard/clearance/${row.id}`)}
pagination={{
pageIndex: generalPager.pagination.pageIndex,
pageSize: PAGE_SIZE,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination: generalPager.pagination },
onPaginationChange: generalPager.setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
)}
</Paper>
</PageContainer>
);
}

View File

@@ -9,6 +9,7 @@ import {
TextInput,
ThemeIcon,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
ArrowRight,
@@ -76,10 +77,15 @@ export default function ContractRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
const tabStatuses = getStatusesForTab(activeTab);
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
const filter: ContractListFilter = useMemo(
() => ({
page: pagination.pageIndex + 1,
@@ -87,9 +93,17 @@ export default function ContractRequestsPage() {
sortBy: "createdAt",
sortOrder: "DESC",
tab: activeTab,
// Server-side free-text search (contract reference, customer name).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(tabStatuses ? { statuses: tabStatuses } : {}),
}),
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
[
pagination.pageIndex,
pagination.pageSize,
activeTab,
tabStatuses,
debouncedQuery,
],
);
const { data, isLoading, isError, refetch, isFetching } =
@@ -100,16 +114,10 @@ export default function ContractRequestsPage() {
refetch: refetchSummary,
} = useContractListSummary(filter);
const rows = useMemo(() => {
const items = (data?.items ?? []).map(toContractListRow);
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(
(c) =>
c.reference.toLowerCase().includes(q) ||
c.customerLabel.toLowerCase().includes(q),
);
}, [data?.items, query]);
const rows = useMemo(
() => (data?.items ?? []).map(toContractListRow),
[data?.items],
);
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
@@ -346,7 +354,10 @@ export default function ContractRequestsPage() {
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
@@ -354,7 +365,10 @@ export default function ContractRequestsPage() {
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>

View File

@@ -26,6 +26,7 @@ import {
TextInput,
ThemeIcon,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
@@ -46,6 +47,7 @@ type ActiveDialog = "edit" | "options" | "delete";
export default function DropdownSettingsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [createOpen, setCreateOpen] = useState(false);
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
@@ -59,36 +61,35 @@ export default function DropdownSettingsPage() {
};
const closeDialog = () => setActiveDialog(null);
// Table data: server-side pagination + search via GET /dropdown-settings/paged.
const listQuery = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery.trim() || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery],
);
const { data, isLoading, isError, error } = useQuery(
api.dropdownSettings.listPaged.queryOptions({ input: { query: listQuery } }),
);
// Full (unpaged) list feeds the KPI strip only — its aggregates span every
// setting, not just the current page.
const { data: allSettings, isLoading: kpiLoading } = useQuery(
api.dropdownSettings.list.queryOptions(),
);
const deleteMutation = useMutation(api.dropdownSettings.remove.mutationOptions());
const dropdownSettings = useMemo<DropdownSetting[]>(
() => (Array.isArray(data) ? data : []),
[data],
() => (Array.isArray(allSettings) ? allSettings : []),
[allSettings],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return dropdownSettings;
return dropdownSettings.filter(
(s) =>
s.code.toLowerCase().includes(q) ||
s.label.toLowerCase().includes(q) ||
(s.description ?? "").toLowerCase().includes(q),
);
}, [dropdownSettings, query]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const rows = data?.items ?? [];
const total = data?.meta.total ?? 0;
const pageCount = Math.max(1, data?.meta.totalPages ?? 1);
const totalOptions = dropdownSettings.reduce(
(sum, s) => sum + (s.children?.length ?? 0),
@@ -269,7 +270,7 @@ export default function DropdownSettingsPage() {
/>
<KpiStrip
loading={isLoading}
loading={kpiLoading}
items={[
{ label: "Settings", value: dropdownSettings.length, icon: Settings },
{ label: "Total Options", value: totalOptions, icon: Boxes },
@@ -299,7 +300,7 @@ export default function DropdownSettingsPage() {
<DataTable
columns={columns}
data={paginatedData}
data={rows}
status={status}
error={
isError

View File

@@ -38,8 +38,8 @@ import {
type FormFieldDef,
} from "@/pages/ruleEngine/config/resources";
import {
useRuleEngineList,
useRuleEngineMutations,
useRuleEngineOrderList,
useWagonTypeOptions,
} from "@/hooks/rule-engine/useRuleEngine";
import type { RuleEngineRecord } from "@/types/rule-engine";
@@ -108,14 +108,14 @@ const CargoTypesPage = () => {
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
// One fetch of the whole (small) set; the tree, ancestry and each level are
// derived client-side so drilling between levels is instant.
const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, {
page: 1,
pageSize: 500,
sortBy: "displayOrder",
sortOrder: "ASC",
});
// One fetch of the whole (small) set — page-walked because the API caps
// pageSize at 100; the tree, ancestry and each level are derived client-side
// so drilling between levels is instant.
const { data, isLoading, isError } = useRuleEngineOrderList(
CARGO_SLUG,
true,
"displayOrder",
);
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
@@ -141,7 +141,7 @@ const CargoTypesPage = () => {
const [formMode, setFormMode] = useState<FormMode | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
const all = (data?.data ?? []) as CargoNode[];
const all = (data ?? []) as CargoNode[];
const { byId, childrenOf } = useMemo(() => {
const byId = new Map<string, CargoNode>(all.map((n) => [n.id, n]));

View File

@@ -210,7 +210,7 @@ const RuleEngineResourcePage = () => {
});
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
const rows = data?.data ?? [];
const rows = data?.items ?? [];
const meta = data?.meta;
const pageCount = meta?.totalPages ?? 1;
const totalCount = meta?.total ?? rows.length;
@@ -223,15 +223,15 @@ const RuleEngineResourcePage = () => {
);
const createPositionOptions = useMemo(() => {
if (!config?.orderConfig || !createPositionList?.data?.length)
if (!config?.orderConfig || !createPositionList?.length)
return undefined;
return createPositionList.data
return createPositionList
.filter((row) => row.id)
.map((row) => ({
label: getOrderItemLabel(row, config.slug),
value: String(row.id),
}));
}, [config?.orderConfig, config?.slug, createPositionList?.data]);
}, [config?.orderConfig, config?.slug, createPositionList]);
const handleApproveRate = useCallback(
(record: RuleEngineRecord) => {
@@ -528,7 +528,7 @@ const RuleEngineResourcePage = () => {
open={orderDialogOpen}
onOpenChange={setOrderDialogOpen}
config={config}
items={orderListData?.data ?? []}
items={orderListData ?? []}
isLoading={orderListLoading}
isSaving={reorder.isPending}
onSave={(payload) => {

View File

@@ -244,6 +244,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration",
subtitle: "Configure container sizes",
searchPlaceholder: "Search container types...",
supportsSearch: true,
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
@@ -273,6 +274,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration",
subtitle: "Configure wagon classes used for capacity and train planning",
searchPlaceholder: "Search wagon types by name or code...",
// No supportsSearch: wagon-types is served by its own module, which does
// not implement server-side search (unlike the 9 rule-engine resources).
cardTitleKey: "name",
columns: [
codeColumn("code"),
@@ -316,6 +319,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "rules",
subtitle: "Wagon-count, payment-currency, and customs scoring rules",
searchPlaceholder: "Search priority rules...",
supportsSearch: true,
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
{ id: "type", header: "Type", accessorKey: "type" },
@@ -379,6 +383,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "rules",
subtitle: "VGM limits by container and trade direction",
searchPlaceholder: "Search weight limit rules...",
supportsSearch: true,
cardTitleKey: "containerType",
cardSubtitleKey: "tradeDirection",
columns: [
@@ -429,6 +434,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration",
subtitle: "Terminal and yard locations",
searchPlaceholder: "Search yards...",
supportsSearch: true,
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
codeColumn("code"),
@@ -455,6 +461,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
category: "configuration",
subtitle: "Shipping line codes and pricing mappings",
searchPlaceholder: "Search shipping lines...",
supportsSearch: true,
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
@@ -483,6 +490,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
cardSubtitleKey: "currency",
subtitle: "Freight rates and approval workflow",
searchPlaceholder: "Search rates by type or status...",
supportsSearch: true,
columns: [
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
@@ -560,6 +568,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
cardSubtitleKey: "requiredRole",
subtitle: "Multi-step booking approval chain",
searchPlaceholder: "Search approval rules...",
supportsSearch: true,
orderConfig: {
field: "stepOrder",
scopeField: "requiresDirectorApproval",

View File

@@ -55,6 +55,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import type {
BatchBoardFilters,
BatchBoardSchedule,
@@ -424,6 +425,8 @@ function CardSkeleton() {
export default function BatchBoardPage() {
const navigate = useNavigate();
// Live board: phase + batch-changed pushes invalidate the list query below.
useBookingWindowSocket();
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
const { pagination, setPagination } = usePagination({ pageSize: 12 });
const [search, setSearch] = useState("");
@@ -484,13 +487,17 @@ export default function BatchBoardPage() {
const { data, isLoading, isError, isFetching, refetch } = useQuery({
...api.trainScheduling.batchBoard.queryOptions({ input: { filters } }),
refetchInterval: 30_000,
// Real-time updates come from the booking-window socket (batch-board:changed
// + PHASE pushes invalidate this query); 60s is only a self-heal safety net
// for a missed emit.
refetchInterval: 60_000,
placeholderData: keepPreviousData,
});
const schedules = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = data?.totalPages ?? 1;
const total = data?.meta.total ?? 0;
// The table footer expects at least one page even when the board is empty.
const pageCount = Math.max(1, data?.meta.totalPages ?? 1);
const summary = useMemo(() => {
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { memo, useCallback, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Accordion,
@@ -377,7 +377,15 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
},
];
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
// Memoized: the page re-renders on unrelated state (tab switch, composition
// booking selection, background-fetch flags) while the bookings arrays keep
// their identity (useMemo + React Query structural sharing) — skip re-rendering
// the whole table in those cases.
const BookingTable = memo(function BookingTable({
bookings,
}: {
bookings: BatchBoardBookingDetail[];
}) {
return (
<DataTable
columns={BOOKING_COLUMNS}
@@ -387,7 +395,7 @@ function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
/>
);
}
});
function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
const chips: Array<{ value: number; color: string; label: string }> = [
@@ -581,18 +589,10 @@ export default function BatchScheduleDetailPage() {
api.trainScheduling.batchBoardDetail.queryOptions({
input: { scheduleId: scheduleId ?? "" },
enabled: Boolean(scheduleId),
// Poll fast while a window cycle is actively moving (open / doc-review /
// payment) so the priority ranking + pay countdowns stay live; back off to
// 30s once the cycle is idle (pre-window / closed / done).
refetchInterval: (query) => {
const phase = (query.state.data as BatchBoardScheduleDetail | undefined)
?.windowPhase;
return phase === "OPEN" ||
phase === "DOC_REVIEW" ||
phase === "PAYMENT"
? 5_000
: 30_000;
},
// Real-time updates come from the booking-window socket (batch-board:changed
// + PHASE pushes invalidate this query); 60s is only a self-heal safety net
// for a missed emit.
refetchInterval: 60_000,
}),
);
// Keep the board in sync with server-pushed window-phase transitions too
@@ -688,6 +688,10 @@ export default function BatchScheduleDetailPage() {
null,
);
// Stable identity so the memoized BookingsManager isn't re-rendered by
// unrelated page state (tab switches, composition selection, fetch flags).
const handleBookingsChanged = useCallback(() => void refetch(), [refetch]);
const handleCompleteDocReview = () => {
completeDocReview
.mutateAsync(scheduleId ?? "")
@@ -1002,7 +1006,7 @@ export default function BatchScheduleDetailPage() {
<BookingsManager
scheduleId={scheduleId ?? ""}
bookings={allBookings}
onChanged={() => void refetch()}
onChanged={handleBookingsChanged}
readOnly={bookingsReadOnly}
/>
</Paper>

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { memo, useMemo, useState } from "react";
import {
ActionIcon,
Badge,
@@ -118,8 +118,11 @@ export interface BookingsManagerProps {
* allocation status, and remove or re-assign bookings individually or in bulk.
* Wraps the shared DataTable; selection + actions are handled locally so the
* surrounding accordion / tab layout stays untouched.
*
* Memoized: the detail page passes stable props (memoized bookings array +
* useCallback onChanged), so its unrelated re-renders skip this subtree.
*/
export function BookingsManager({
export const BookingsManager = memo(function BookingsManager({
scheduleId,
bookings,
onChanged,
@@ -620,6 +623,6 @@ export function BookingsManager({
</Modal>
</Stack>
);
}
});
export default BookingsManager;

View File

@@ -15,6 +15,7 @@ import {
TextInput,
ThemeIcon,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { isAxiosError } from "axios";
import {
ArrowRight,
@@ -29,7 +30,7 @@ import {
Train,
Weight,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import FleetToolbar from "@/components/fleet/FleetToolbar";
@@ -48,11 +49,16 @@ import {
RouteCorridor,
StatusPill,
} from "@/components/trainScheduling/scheduleVisuals";
import { useMutation, useQuery } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { formatRouteLabel } from "@/services/routes.service";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleListItem } from "@/types/trainScheduling";
import type {
FreightType,
TrainScheduleListFilters,
TrainScheduleListItem,
TrainScheduleStatus,
} from "@/types/trainScheduling";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
/** `min` for a `datetime-local` input: now, in the browser's local zone. */
@@ -94,14 +100,18 @@ export default function TrainScheduleV2ListPage() {
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [freightFilter, setFreightFilter] = useState("ALL");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [statusFilter, setStatusFilter] = useState<"ALL" | TrainScheduleStatus>("ALL");
const [freightFilter, setFreightFilter] = useState<"ALL" | FreightType>("ALL");
// Origin/destination hold yard IDs ("ALL" = no filter); the server matches
// the schedule's origin_station_id / destination_station_id exactly.
const [originFilter, setOriginFilter] = useState("ALL");
const [destinationFilter, setDestinationFilter] = useState("ALL");
// Default: newest-created first, matching the API's default order.
const [sortBy, setSortBy] = useState<"createdAt" | "scheduleDate" | "reference">(
"createdAt",
);
// Default: newest-created first, matching the API's default order. Values
// are the server sort fields (see TRAIN_SCHEDULE_SORT_FIELDS).
const [sortBy, setSortBy] = useState<
"createdAt" | "scheduledDepartureDate" | "reference"
>("createdAt");
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
@@ -117,8 +127,61 @@ export default function TrainScheduleV2ListPage() {
[createOpen],
);
const resetPage = useCallback(() => {
setPagination((prev) =>
prev.pageIndex === 0 ? prev : { ...prev, pageIndex: 0 },
);
}, [setPagination]);
// Search resets the page only once the debounced value settles — resetting
// per keystroke would refetch page 1 mid-typing.
useEffect(() => {
resetPage();
}, [debouncedSearch, resetPage]);
// Fully server-driven list: pagination, search, filters, and sort all travel
// as query params; the response envelope carries the page + totals.
const filters = useMemo<TrainScheduleListFilters>(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
...(freightFilter !== "ALL" ? { freightType: freightFilter } : {}),
...(originFilter !== "ALL" ? { originStationId: originFilter } : {}),
...(destinationFilter !== "ALL"
? { destinationStationId: destinationFilter }
: {}),
sortBy,
sortOrder: sortDir === "asc" ? "ASC" : "DESC",
}),
[
pagination.pageIndex,
pagination.pageSize,
debouncedSearch,
statusFilter,
freightFilter,
originFilter,
destinationFilter,
sortBy,
sortDir,
],
);
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
api.trainScheduling.scheduleList.queryOptions({
input: { filters },
// Keep the previous page on screen while the next page/filter result
// loads instead of flashing the empty state; 30s staleTime spares
// back-and-forth navigation from refetching an unchanged list.
placeholderData: keepPreviousData,
staleTime: 30_000,
}),
);
// Yard options for the origin/destination filters (shared routes reference
// list, so the choices don't shrink to whatever the current page shows).
const yardsQuery = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
);
const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
@@ -153,96 +216,40 @@ export default function TrainScheduleV2ListPage() {
setLocomotiveIds([]);
}, [routeId]);
const allSchedules = schedulesQuery.data ?? [];
// Filtering, sorting, and paging all happen server-side — `schedules` IS the
// current page, and the meta envelope carries the totals.
const schedules = schedulesQuery.data?.items ?? [];
const totalSchedules = schedulesQuery.data?.meta.total ?? 0;
const pageCount = Math.max(1, schedulesQuery.data?.meta.totalPages ?? 1);
// Status/weight tiles count the visible page only — board-wide numbers would
// need a dedicated summary endpoint now that the list is server-paginated.
const stats = useMemo(() => {
const base = {
total: allSchedules.length,
scheduled: 0,
dispatched: 0,
draft: 0,
weight: 0,
};
for (const s of allSchedules) {
for (const s of schedules) {
if (s.status === "SCHEDULED") base.scheduled += 1;
if (s.status === "DISPATCHED") base.dispatched += 1;
if (s.status === "DRAFT") base.draft += 1;
base.weight += s.totalWeightTons ?? 0;
}
return base;
}, [allSchedules]);
}, [schedules]);
// Distinct origins/destinations present in the loaded schedules, for the
// corridor filters. Sorted A→Z; "ALL" prepended by the Select data below.
const originOptions = useMemo(
// Corridor filter options: every yard from the shared reference list, sent
// to the server as origin/destination station IDs.
const yardOptions = useMemo(
() =>
[...new Set(allSchedules.map((s) => s.origin).filter(Boolean))].sort() as string[],
[allSchedules],
(yardsQuery.data ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
})),
[yardsQuery.data],
);
const destinationOptions = useMemo(
() =>
[
...new Set(allSchedules.map((s) => s.destination).filter(Boolean)),
].sort() as string[],
[allSchedules],
);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
const matched = allSchedules.filter((s) => {
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
if (originFilter !== "ALL" && s.origin !== originFilter) return false;
if (destinationFilter !== "ALL" && s.destination !== destinationFilter)
return false;
if (!query) return true;
const haystack = [
s.reference,
s.trainNumber,
s.routeName,
s.origin,
s.destination,
s.locomotive?.code,
...(s.locomotives ?? []).map((l) => l.code),
s.freightType,
s.status,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
const dir = sortDir === "asc" ? 1 : -1;
const sorted = [...matched].sort((a, b) => {
let cmp = 0;
if (sortBy === "reference") {
cmp = (a.reference ?? "").localeCompare(b.reference ?? "");
} else {
// createdAt or scheduleDate — compare as timestamps (missing sorts last).
const av = new Date(a[sortBy] ?? 0).getTime();
const bv = new Date(b[sortBy] ?? 0).getTime();
cmp = av - bv;
}
return cmp * dir;
});
return sorted;
}, [
allSchedules,
search,
statusFilter,
freightFilter,
originFilter,
destinationFilter,
sortBy,
sortDir,
]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
const paged = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filtered.slice(start, start + pagination.pageSize);
}, [filtered, pagination]);
const columns = useMemo((): ColumnDef<TrainScheduleListItem>[] => {
const headerClassName = ruleEngineTable.headerCell;
@@ -504,7 +511,7 @@ export default function TrainScheduleV2ListPage() {
<KpiStrip
items={[
{ label: "Total trains", value: stats.total, icon: Train },
{ label: "Total trains", value: totalSchedules, icon: Train },
{ label: "Scheduled", value: stats.scheduled, icon: CalendarClock },
{ label: "Dispatched", value: stats.dispatched, icon: Send },
{ label: "Planned load", value: `${Math.round(stats.weight)}T`, icon: Weight },
@@ -526,12 +533,17 @@ export default function TrainScheduleV2ListPage() {
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => v && setStatusFilter(v)}
onChange={(v) => {
if (!v) return;
setStatusFilter(v as "ALL" | TrainScheduleStatus);
resetPage();
}}
data={[
{ value: "ALL", label: "All statuses" },
{ value: "DRAFT", label: "Draft" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "DISPATCHED", label: "Dispatched" },
{ value: "ARRIVED", label: "Arrived" },
{ value: "CANCELLED", label: "Cancelled" },
]}
w={150}
@@ -541,7 +553,11 @@ export default function TrainScheduleV2ListPage() {
size="sm"
radius="lg"
value={freightFilter}
onChange={(v) => v && setFreightFilter(v)}
onChange={(v) => {
if (!v) return;
setFreightFilter(v as "ALL" | FreightType);
resetPage();
}}
data={[
{ value: "ALL", label: "All freight" },
{ value: "CONTAINER", label: "Container" },
@@ -557,10 +573,13 @@ export default function TrainScheduleV2ListPage() {
placeholder="Origin"
searchable
value={originFilter}
onChange={(v) => setOriginFilter(v ?? "ALL")}
onChange={(v) => {
setOriginFilter(v ?? "ALL");
resetPage();
}}
data={[
{ value: "ALL", label: "All origins" },
...originOptions.map((o) => ({ value: o, label: o })),
...yardOptions,
]}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
@@ -571,10 +590,13 @@ export default function TrainScheduleV2ListPage() {
placeholder="Destination"
searchable
value={destinationFilter}
onChange={(v) => setDestinationFilter(v ?? "ALL")}
onChange={(v) => {
setDestinationFilter(v ?? "ALL");
resetPage();
}}
data={[
{ value: "ALL", label: "All destinations" },
...destinationOptions.map((d) => ({ value: d, label: d })),
...yardOptions,
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
@@ -591,12 +613,13 @@ export default function TrainScheduleV2ListPage() {
];
setSortBy(by);
setSortDir(dir);
resetPage();
}}
data={[
{ value: "createdAt:desc", label: "Newest created" },
{ value: "createdAt:asc", label: "Oldest created" },
{ value: "scheduleDate:desc", label: "Departure ↓" },
{ value: "scheduleDate:asc", label: "Departure ↑" },
{ value: "scheduledDepartureDate:desc", label: "Departure ↓" },
{ value: "scheduledDepartureDate:asc", label: "Departure ↑" },
{ value: "reference:asc", label: "Reference ↑" },
{ value: "reference:desc", label: "Reference ↓" },
]}
@@ -611,7 +634,7 @@ export default function TrainScheduleV2ListPage() {
{viewMode === "table" ? (
<DataTable
columns={columns}
data={paged}
data={schedules}
status={tableStatus}
onRowClick={(schedule) =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
@@ -629,7 +652,7 @@ export default function TrainScheduleV2ListPage() {
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filtered.length,
totalCount: totalSchedules,
}}
tableOptions={{
manualPagination: true,
@@ -648,13 +671,13 @@ export default function TrainScheduleV2ListPage() {
/>
) : (
<Stack gap={0}>
{!paged.length ? (
{!schedules.length ? (
<Text py="xl" ta="center" c="dimmed" size="sm">
No train schedules found
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" p="md">
{paged.map((schedule) => (
{schedules.map((schedule) => (
<ScheduleCard
key={schedule.id}
schedule={schedule}
@@ -675,7 +698,7 @@ export default function TrainScheduleV2ListPage() {
<RuleEngineListFooter
pagination={pagination}
pageCount={pageCount}
totalCount={filtered.length}
totalCount={totalSchedules}
itemLabel="schedules"
onPaginationChange={setPagination}
/>