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

@@ -52,6 +52,7 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa
import ContractViewPage from "./pages/contracts/ContractViewPage";
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
import ClearanceDocumentsPage from "./pages/contracts/ClearanceDocumentsPage";
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
@@ -153,6 +154,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <FileSignature />,
permission: FREIGHT_PERMS.contracts.view,
},
// Operations hub: clearance-document review for contracts WITHOUT
// customs clearing (contract-level for one-time, per-booking for general).
{
label: "Clearance Documents",
href: "/dashboard/contracts/clearance-documents",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
},
{
label: "Customers",
href: "/dashboard/customers",
@@ -793,6 +802,18 @@ const App = () => {
</RequirePermission>
}
/>
{/* Operations hub: clearance documents for non-customs contracts —
Contracts tab (contract-level) + General tab (per-booking). */}
<Route
path="contracts/clearance-documents"
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
>
<ClearanceDocumentsPage />
</RequirePermission>
}
/>
{/* GL (Path B) contract clearance review hub */}
<Route
path="contracts/clearance"

View File

@@ -24,9 +24,10 @@ export interface GlShipmentQuantities {
hazardousQuantity: number;
reeferQuantity: number;
}>;
/** Bulk: tons (or item count) + hazardous qty. */
/** Bulk: tons (or item count) + hazardous/reefer qty. */
bulkQuantity: number;
bulkHazardousQuantity: number;
bulkReeferQuantity: number;
}
/**
@@ -113,6 +114,30 @@ export function computeGlShipmentTotal(
amount: rate.unitPrice * qty,
});
}
if (contract.isHazardous && q.bulkHazardousQuantity > 0) {
const hz = rateFor((i) => i.conditionalOn === "is_hazardous");
if (hz) {
lines.push({
label: hz.label,
unitPrice: hz.unitPrice,
unit: hz.unit,
quantity: q.bulkHazardousQuantity,
amount: hz.unitPrice * q.bulkHazardousQuantity,
});
}
}
if (contract.isReefer && q.bulkReeferQuantity > 0) {
const rf = rateFor((i) => i.conditionalOn === "is_reefer");
if (rf) {
lines.push({
label: rf.label,
unitPrice: rf.unitPrice,
unit: rf.unit,
quantity: q.bulkReeferQuantity,
amount: rf.unitPrice * q.bulkReeferQuantity,
});
}
}
}
const total = lines.reduce((s, l) => s + l.amount, 0);

View File

@@ -137,8 +137,12 @@ export function AllocateBookingWizard({
enabled: opened,
}),
);
// Paginated {items, meta} list; the newest 100 schedules comfortably cover
// every DRAFT schedule the wizard can attach to.
const schedulesQuery = useQuery(
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
api.trainScheduling.scheduleList.queryOptions({
input: { filters: { pageSize: 100 } },
}),
);
const routesQuery = useQuery(
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
@@ -163,7 +167,7 @@ export function AllocateBookingWizard({
const matchingSchedules = useMemo(
() =>
(schedulesQuery.data ?? []).filter(
(schedulesQuery.data?.items ?? []).filter(
(s: TrainScheduleListItem) =>
s.status === "DRAFT" &&
(!s.freightType || s.freightType === "MIXED" || s.freightType === bookingFreightType),

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { memo, useMemo, useState } from "react";
import {
Box,
Group,
@@ -267,7 +267,13 @@ function CapacityDivider({ used, max }: { used: number; max: number | null }) {
);
}
export function PriorityTrackingTab({ data, bookings }: Props) {
// Memoized: mounted in a keep-mounted Tabs panel, so it re-renders with every
// page render; both props keep their identity across unrelated page state
// (React Query structural sharing + the page's useMemo'd bookings).
export const PriorityTrackingTab = memo(function PriorityTrackingTab({
data,
bookings,
}: Props) {
const phase = data.windowPhase;
const isPayPhase = phase === "PAYMENT";
@@ -558,7 +564,7 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
) : null}
</Stack>
);
}
});
/** Contextual banner describing the current window phase in plain language. */
function PhaseBanner({ phase }: { phase: string | null }) {

View File

@@ -100,7 +100,8 @@ export const QUERY_KEYS = {
locomotives: (routeId?: string) =>
["train-scheduling", "locomotives", routeId ?? "all"] as const,
stations: () => ["train-scheduling", "stations"] as const,
schedules: () => ["train-scheduling", "schedules"] as const,
schedules: (filters?: unknown) =>
["train-scheduling", "schedules", filters ?? {}] as const,
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
track: (id: string) => ["train-scheduling", "track", id] as const,
batchBoard: (filters?: unknown) =>

View File

@@ -1,6 +1,7 @@
import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BatchBoardChangedEvent,
type BookingWindowPhaseEvent,
} from "@edr/types";
import { useQueryClient } from "@tanstack/react-query";
@@ -82,9 +83,19 @@ export function useBookingWindowSocket(enabled: boolean = true) {
// Deliberate console breadcrumbs: "live updates not arriving" is only
// diagnosable from the browser when connect/reject outcomes are visible.
socket.on("connect", () =>
console.debug("[booking-windows] socket connected", socket.id),
);
let hadConnected = false;
socket.on("connect", () => {
console.debug("[booking-windows] socket connected", socket.id);
// A RE-connect means pushes may have been missed while offline — refetch
// every board view (list + any open detail share the "batch-board" key
// prefix) once so the gap self-heals immediately.
if (hadConnected) {
void qc.invalidateQueries({
queryKey: ["train-scheduling", "batch-board"],
});
}
hadConnected = true;
});
socket.on("connect_error", (err) =>
console.warn("[booking-windows] socket connect failed:", err.message),
);
@@ -144,6 +155,19 @@ export function useBookingWindowSocket(enabled: boolean = true) {
},
);
// Board-data pushes (payment settled, allocation, fill, expiry, …): refetch
// the schedule's detail immediately, refresh the list debounced. The event
// carries no data — the board views are rich, differently-shaped queries.
socket.on(
BOOKING_WINDOW_WS_EVENTS.BATCH_CHANGED,
(event: BatchBoardChangedEvent) => {
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId),
});
scheduleRefetch(false);
},
);
return () => {
if (refetchTimer) clearTimeout(refetchTimer);
socket.off();

View File

@@ -211,7 +211,9 @@ export function useContractMutations(contractId: string) {
toast.success("Booking created under contract");
void invalidateContractDetail(qc, contractId);
},
onError: () => toast.error("Failed to create booking"),
// Surface the server's reason (e.g. a container already booked on the same
// train) instead of a generic failure.
onError: (e: Error) => toast.error(e.message || "Failed to create booking"),
});
const completeBooking = useMutation({

View File

@@ -14,9 +14,6 @@ import {
patchRuleEngineListRecord,
} from "@/utils/queryInvalidation";
const CARGO_TYPE_PARENT_PAGE_SIZE = 500;
const CONTAINER_TYPE_OPTIONS_PAGE_SIZE = 500;
export const useRuleEngineList = (
resource: RuleEngineResourceSlug,
params: RuleEngineListParams,
@@ -26,8 +23,7 @@ export const useRuleEngineList = (
queryFn: () => ruleEngineService.list(resource, params),
});
const ORDER_LIST_PAGE_SIZE = 500;
/** Full (page-walked) list used by the reorder dialog and create-position picker. */
export const useRuleEngineOrderList = (
resource: RuleEngineResourceSlug,
enabled: boolean,
@@ -36,9 +32,7 @@ export const useRuleEngineOrderList = (
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource),
queryFn: () =>
ruleEngineService.list(resource, {
page: 1,
pageSize: ORDER_LIST_PAGE_SIZE,
ruleEngineService.listAll(resource, {
sortBy,
sortOrder: "ASC",
}),
@@ -75,15 +69,11 @@ export const useRuleEngineOrderMutations = (resource: RuleEngineResourceSlug) =>
export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types"),
queryFn: () =>
ruleEngineService.list<RuleEngineRecord>("cargo-types", {
page: 1,
pageSize: CARGO_TYPE_PARENT_PAGE_SIZE,
}),
queryFn: () => ruleEngineService.listAll<RuleEngineRecord>("cargo-types"),
enabled,
select: (result) => {
select: (rows) => {
const noneOption = { label: "None", value: RULE_ENGINE_SELECT_NONE };
const parents = (result.data ?? [])
const parents = rows
.filter((row) => row.id && String(row.id) !== excludeId)
.map((row) => {
const name = String(row.cargoTypeName ?? "").trim();
@@ -104,14 +94,9 @@ export const useCargoTypeParentOptions = (excludeId?: string, enabled = true) =>
export const useCargoLeafOptions = (enabled = true) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("cargo-types", { leafOnly: true }),
queryFn: () =>
ruleEngineService.list<RuleEngineRecord>("cargo-types", {
page: 1,
pageSize: CARGO_TYPE_PARENT_PAGE_SIZE,
}),
queryFn: () => ruleEngineService.listAll<RuleEngineRecord>("cargo-types"),
enabled,
select: (result) => {
const rows = result.data ?? [];
select: (rows) => {
const parentIds = new Set(
rows
.map((row) => row.parentGroupId)
@@ -157,21 +142,12 @@ export const useContainerTypeOptions = (
) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions('container-types', {
page: 1,
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
includeNone,
}),
queryFn: () =>
api.ruleEngine.list.call({
resource: "container-types",
params: {
page: 1,
pageSize: CONTAINER_TYPE_OPTIONS_PAGE_SIZE,
},
}),
ruleEngineService.listAll<RuleEngineRecord>("container-types"),
enabled,
select: (result) =>
buildContainerTypeSelectOptions(result.data ?? [], includeNone),
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
});
/**
@@ -191,20 +167,14 @@ export const useWagonTypeOptions = (enabled = true) =>
})),
});
const LIVE_RATE_PAGE_SIZE = 500;
export const useLiveRateOptions = (enabled = true) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("rates", { status: "LIVE" }),
queryFn: () =>
ruleEngineService.list<RuleEngineRecord>("rates", {
page: 1,
pageSize: LIVE_RATE_PAGE_SIZE,
status: "LIVE",
}),
ruleEngineService.listAll<RuleEngineRecord>("rates", { status: "LIVE" }),
enabled,
select: (result) =>
(result.data ?? [])
select: (rows) =>
rows
.filter((row) => row.id)
.map((row) => {
const rateType = String(row.rateType ?? "").replace(/_/g, " ");

View File

@@ -57,7 +57,7 @@ export function useWarehouse(id?: string) {
export function useWarehouseFacilities() {
return useQuery({
queryKey: warehouseKeys.facilities(),
queryFn: () => warehouseService.listFacilities().then((r) => r.data),
queryFn: () => warehouseService.listFacilities().then((r) => r.data.items),
});
}

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

View File

@@ -20,6 +20,8 @@ import {
CreateDropdownSettingDto,
DropdownOption,
DropdownSetting,
DropdownSettingListQuery,
PaginatedDropdownSettings,
UpdateDropdownOptionDto,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
@@ -61,7 +63,8 @@ import type {
StaffBookingWindow,
TrainScheduleDetail,
TrainScheduleFilters,
TrainScheduleListItem,
TrainScheduleListFilters,
TrainScheduleListResponse,
UpdateScheduleWindowRulePayload,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
@@ -214,13 +217,14 @@ export const api = {
trainScheduling: {
// ── Queries ────────────────────────────────────────────────────────────
scheduleList: endpoint<
{ freightType?: FreightType },
TrainScheduleListItem[]
{ freightType?: FreightType; filters?: TrainScheduleListFilters },
TrainScheduleListResponse
>(
"train-scheduling",
"schedules",
({ freightType }) => trainSchedulingService.listSchedules(freightType),
() => QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
({ freightType, filters }) =>
trainSchedulingService.listSchedules(freightType, filters),
({ filters }) => QUERY_KEYS.TRAIN_SCHEDULING.schedules(filters),
),
batchBoard: endpoint<
@@ -1398,7 +1402,7 @@ export const api = {
yards: endpoint<void, YardRef[]>(
"routes",
"yards",
() => routesService.getYards().then((r) => r.data.data),
() => routesService.getYards(),
() => ["routes", "yards"],
),
@@ -1970,6 +1974,15 @@ export const api = {
dropdownSettingsService.list,
),
listPaged: endpoint<
{ query: DropdownSettingListQuery },
PaginatedDropdownSettings
>(
"dropdown-settings",
"listPaged",
({ query }) => dropdownSettingsService.listPaged(query),
),
getById: endpoint<{ id: string }, DropdownSetting>(
"dropdown-settings",
"getById",

View File

@@ -19,6 +19,8 @@ export interface BookingListFilter {
freightType?: string;
/** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */
bookingType?: string;
/** 'true' → customs bookings, 'false' → self-clearance (non-customs). */
customsClearingEnabled?: "true" | "false";
tradeDirection?: string;
paymentCurrency?: string;
paymentStatus?: string;
@@ -34,6 +36,8 @@ export interface BookingListFilter {
destinationYardId?: string;
/** "true" = government bookings only, "false" = private only. */
isGovernment?: "true" | "false";
/** Free-text search: booking reference, customer name, contract reference (server-side). */
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
@@ -183,6 +187,9 @@ export const bookingsService = {
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customsClearingEnabled)
params.customsClearingEnabled = filter.customsClearingEnabled;
if (filter.search) params.search = filter.search;
}
const response = await client.get<PaginatedBookings>(B.BASE, {
params,

View File

@@ -1,15 +1,8 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
import { ruleEngineService } from "./ruleEngine/ruleEngine.service";
export const cargoTypesService = {
/** All active cargo types (page-walked — the API caps pageSize at 100). */
async getCargoTypes() {
const response = await api.get<ListResponse<unknown>>('/cargo-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
return ruleEngineService.listAll("cargo-types", { isActive: true });
},
};

View File

@@ -1,15 +1,8 @@
import { api } from "../auth/http";
type ListResponse<T> = T[] | { data: T[] };
const asList = <T>(payload: ListResponse<T>): T[] =>
Array.isArray(payload) ? payload : payload.data;
import { ruleEngineService } from "./ruleEngine/ruleEngine.service";
export const containerTypesService = {
/** All active container types (page-walked — the API caps pageSize at 100). */
async getContainerTypes() {
const response = await api.get<ListResponse<unknown>>('/container-types', {
params: { isActive: true, pageSize: 500 },
});
return asList(response.data);
return ruleEngineService.listAll("container-types", { isActive: true });
},
};

View File

@@ -16,6 +16,8 @@ export interface ContractListFilter {
tradeDirection?: string;
contractKind?: string;
paymentCurrency?: string;
/** Server-side free-text search (contract reference, company name). */
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
@@ -58,6 +60,10 @@ export interface ShipmentValidation {
pairingErrors: string[];
/** Lines above the container type's hard max capacity — booking cannot be created. */
capacityErrors?: string[];
/** Containers already on another active booking for the same day + route — booking cannot be created. */
containerClashErrors?: string[];
/** EXPORT only: no single open train on the chosen day can carry the whole booking — booking cannot be created. */
spaceErrors?: string[];
lineItems?: ShipmentPriceLine[];
totalAmount?: number;
}
@@ -123,6 +129,7 @@ function buildListParams(filter?: ContractListFilter) {
if (filter) {
if (filter.statuses) params.statuses = filter.statuses;
else if (filter.status) params.status = filter.status;
if (filter.search) params.search = filter.search;
if (filter.page != null) params.page = filter.page;
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.sortBy) params.sortBy = filter.sortBy;
@@ -450,9 +457,14 @@ export const contractsService = {
},
// ── Path A self-clearance (Operations review) ──
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
getOpsClearanceQueue: async (filter?: {
page?: number;
pageSize?: number;
search?: string;
}): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(
C.OPS_CLEARANCE_QUEUE,
{ params: filter },
);
const data = unwrap(response.data);
return {
@@ -467,8 +479,15 @@ export const contractsService = {
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
},
getOpsClearanceHistory: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(C.OPS_CLEARANCE_HISTORY);
getOpsClearanceHistory: async (filter?: {
page?: number;
pageSize?: number;
search?: string;
}): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(
C.OPS_CLEARANCE_HISTORY,
{ params: filter },
);
const data = unwrap(response.data);
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
},

View File

@@ -7,6 +7,8 @@ import type {
CreateDropdownSettingDto,
DropdownOption,
DropdownSetting,
DropdownSettingListQuery,
PaginatedDropdownSettings,
UpdateDropdownOptionDto,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
@@ -19,6 +21,16 @@ export const dropdownSettingsService = {
return unwrap(response.data);
},
listPaged: async (
query: DropdownSettingListQuery,
): Promise<PaginatedDropdownSettings> => {
const response = await client.get<ApiResponse<PaginatedDropdownSettings>>(
`${BASE}/paged`,
{ params: query },
);
return unwrap(response.data);
},
getById: async (id: string): Promise<DropdownSetting> => {
const response = await client.get<ApiResponse<DropdownSetting>>(
URL_CONSTANTS.DROPDOWN_SETTINGS.BY_ID(id),

View File

@@ -1,6 +1,7 @@
import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import { ruleEngineService } from './ruleEngine/ruleEngine.service';
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
@@ -76,10 +77,6 @@ export const ROUTE_STATUS_OPTIONS: Array<{ value: RouteStatus; label: string }>
{ value: 'STOP_WORKING', label: 'Stop working' },
];
interface YardListResponse {
data: YardRef[];
}
export const routesService = {
getAll: (params?: { status?: RouteStatus; search?: string }) =>
apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE, { params }),
@@ -88,8 +85,9 @@ export const routesService = {
update: (id: string, data: Partial<SaveRoutePayload>) =>
apiClient.patch(URL_CONSTANTS.ROUTES.BY_ID(id), data),
deactivate: (id: string) => apiClient.delete(URL_CONSTANTS.ROUTES.BY_ID(id)),
getYards: () =>
apiClient.get<YardListResponse>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
params: { isActive: true, pageSize: 200 },
}),
/** All active yards (page-walked — the yards list API caps pageSize at 100). */
getYards: async (): Promise<YardRef[]> => {
const rows = await ruleEngineService.listAll("yards", { isActive: true });
return rows as unknown as YardRef[];
},
};

View File

@@ -68,50 +68,63 @@ const defaultMeta = (
dataLength: number,
page = 1,
pageSize = 10,
): RuleEngineListMeta => ({
total: dataLength,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(dataLength / pageSize)),
});
): RuleEngineListMeta => {
const totalPages = Math.max(1, Math.ceil(dataLength / pageSize));
return {
total: dataLength,
page,
pageSize,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
};
};
const isPaginatedListResult = <T extends RuleEngineRecord>(
/** Standard envelope from the shared pagination toolkit: `{ items, meta }`. */
const isItemsEnvelope = <T extends RuleEngineRecord>(
value: unknown,
): value is RuleEngineListResult<T> =>
Boolean(value) &&
typeof value === "object" &&
"data" in (value ?? {}) &&
Array.isArray((value as RuleEngineListResult<T>).data);
Array.isArray((value as { items?: unknown }).items);
/** Legacy envelope (`{ data, meta }`) — still returned by wagon-types. */
const isLegacyEnvelope = <T extends RuleEngineRecord>(
value: unknown,
): value is { data: T[]; meta?: RuleEngineListMeta } =>
Boolean(value) &&
typeof value === "object" &&
Array.isArray((value as { data?: unknown }).data);
const normalizeList = <T extends RuleEngineRecord>(
payload: unknown,
page = 1,
pageSize = 10,
): RuleEngineListResult<T> => {
if (isPaginatedListResult<T>(payload)) {
return {
data: payload.data,
meta: payload.meta ?? defaultMeta(payload.data.length, page, pageSize),
};
const candidates: unknown[] = [payload, unwrap(payload as { data: unknown })];
for (const body of candidates) {
if (isItemsEnvelope<T>(body)) {
return {
items: body.items,
meta: body.meta ?? defaultMeta(body.items.length, page, pageSize),
};
}
if (isLegacyEnvelope<T>(body)) {
return {
items: body.data,
meta: body.meta ?? defaultMeta(body.data.length, page, pageSize),
};
}
if (Array.isArray(body)) {
return {
items: body as T[],
meta: defaultMeta(body.length, page, pageSize),
};
}
}
const body = unwrap(payload as { data: unknown }) as unknown;
if (isPaginatedListResult<T>(body)) {
return {
data: body.data,
meta: body.meta ?? defaultMeta(body.data.length, page, pageSize),
};
}
if (Array.isArray(body)) {
return {
data: body as T[],
meta: defaultMeta(body.length, page, pageSize),
};
}
return { data: [], meta: defaultMeta(0, page, pageSize) };
return { items: [], meta: defaultMeta(0, page, pageSize) };
};
const normalizeEntity = <T extends RuleEngineRecord>(payload: unknown): T => {
@@ -140,6 +153,34 @@ export const ruleEngineService = {
return normalizeList<T>(response.data, page, pageSize);
},
/**
* Fetch every row of a resource by walking the pages. The API caps pageSize
* at 100, so option/dropdown consumers that used to ask for 200-500 rows in
* one shot go through here instead of getting silently capped (or a 400).
*/
listAll: async <T extends RuleEngineRecord>(
resource: RuleEngineResourceSlug,
params?: Omit<RuleEngineListParams, "page" | "pageSize">,
): Promise<T[]> => {
const pageSize = 100;
const first = await ruleEngineService.list<T>(resource, {
...params,
page: 1,
pageSize,
});
const items = [...first.items];
const totalPages = first.meta.totalPages ?? 1;
for (let page = 2; page <= totalPages; page += 1) {
const next = await ruleEngineService.list<T>(resource, {
...params,
page,
pageSize,
});
items.push(...next.items);
}
return items;
},
getById: async <T extends RuleEngineRecord>(
resource: RuleEngineResourceSlug,
id: string,

View File

@@ -30,7 +30,8 @@ import type {
TrainScheduleDetail,
UpdateScheduleWindowRulePayload,
TrainScheduleFilters,
TrainScheduleListItem,
TrainScheduleListFilters,
TrainScheduleListResponse,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
TrainSchedulingGlobalRules,
@@ -94,9 +95,22 @@ export const trainSchedulingService = {
listSchedules: async (
freightType: FreightType = "CONTAINER",
): Promise<TrainScheduleListItem[]> => {
const response = await client.get<TrainScheduleListItem[]>(
filters: TrainScheduleListFilters = {},
): Promise<TrainScheduleListResponse> => {
const params: Record<string, string | number> = {};
if (filters.page) params.page = filters.page;
if (filters.pageSize) params.pageSize = filters.pageSize;
if (filters.search?.trim()) params.search = filters.search.trim();
if (filters.status) params.status = filters.status;
if (filters.freightType) params.freightType = filters.freightType;
if (filters.originStationId) params.originStationId = filters.originStationId;
if (filters.destinationStationId)
params.destinationStationId = filters.destinationStationId;
if (filters.sortBy) params.sortBy = filters.sortBy;
if (filters.sortOrder) params.sortOrder = filters.sortOrder;
const response = await client.get<TrainScheduleListResponse>(
pathsFor(freightType === "MIXED" ? undefined : freightType).SCHEDULES,
{ params },
);
return unwrap(response.data);
},

View File

@@ -219,7 +219,11 @@ export const warehouseService = {
apiClient.post<Warehouse>(URL_CONSTANTS.WAREHOUSES.BASE, payload),
update: (id: string, payload: Partial<SaveWarehousePayload>) =>
apiClient.patch<Warehouse>(URL_CONSTANTS.WAREHOUSES.BY_ID(id), payload),
listFacilities: () => apiClient.get<WarehouseFacility[]>(URL_CONSTANTS.RULE_ENGINE.YARDS),
// Yards list now returns the standard paginated envelope ({ items, meta }).
listFacilities: () =>
apiClient.get<{ items: WarehouseFacility[] }>(URL_CONSTANTS.RULE_ENGINE.YARDS, {
params: { pageSize: 100 },
}),
// ── Yards ────────────────────────────────────────────────────────────────
listYards: (warehouseId: string) =>

View File

@@ -1,6 +1,6 @@
// Re-export the shared types from @edr/types so existing local imports keep
// working. Canonical source: packages/types/src/freight/dropdown_settings.ts
import type { Freight } from "@edr/types";
import type { Freight, PaginatedResponse } from "@edr/types";
export type DropdownOptionMeta = Freight.IDropdownOptionMeta;
export type DropdownOption = Freight.IDropdownOption;
@@ -10,3 +10,13 @@ export type CreateDropdownOptionDto = Freight.CreateDropdownOptionDto;
export type CreateDropdownSettingDto = Freight.CreateDropdownSettingDto;
export type UpdateDropdownOptionDto = Freight.UpdateDropdownOptionDto;
export type UpdateDropdownSettingDto = Freight.UpdateDropdownSettingDto;
/** Query params for GET /dropdown-settings/paged (server-side search). */
export interface DropdownSettingListQuery {
page?: number;
pageSize?: number;
search?: string;
}
/** Shared paginated envelope returned by GET /dropdown-settings/paged. */
export type PaginatedDropdownSettings = PaginatedResponse<DropdownSetting>;

View File

@@ -10,15 +10,23 @@ export type RuleEngineResourceSlug =
| "rates"
| "approval-rules";
/**
* Mirrors the API's shared `PaginationMeta` (@edr/types). The `has*` flags are
* optional because the legacy wagon-types endpoint still returns the old
* four-field meta.
*/
export interface RuleEngineListMeta {
total: number;
page: number;
pageSize: number;
totalPages: number;
hasNextPage?: boolean;
hasPreviousPage?: boolean;
}
/** Standard paginated envelope (`items` + `meta`) shared by all rule-engine lists. */
export interface RuleEngineListResult<T> {
data: T[];
items: T[];
meta: RuleEngineListMeta;
}

View File

@@ -1,3 +1,5 @@
import type { PaginatedResponse } from "@edr/types";
export type FreightType = "CONTAINER" | "BULK" | "MIXED";
export type SchedulingStatus =
@@ -182,6 +184,33 @@ export interface TrainScheduleListItem {
status: TrainScheduleStatus | string;
}
export type TrainScheduleSortField =
| "createdAt"
| "scheduledDepartureDate"
| "reference"
| "trainNumber"
| "status";
/** Server-side query for the paginated train-schedule list. */
export interface TrainScheduleListFilters {
page?: number;
pageSize?: number;
/** Matches schedule reference, train number, route yards, stations, locomotive code. */
search?: string;
/** Lifecycle status (exact match). */
status?: TrainScheduleStatus;
/** Derived from the bookings aboard: CONTAINER/BULK = only that kind; MIXED = both. */
freightType?: FreightType;
/** Origin station/yard id (exact match). */
originStationId?: string;
/** Destination station/yard id (exact match). */
destinationStationId?: string;
sortBy?: TrainScheduleSortField;
sortOrder?: "ASC" | "DESC";
}
export type TrainScheduleListResponse = PaginatedResponse<TrainScheduleListItem>;
export interface BookableSchedule {
id: string;
scheduleDate: string;
@@ -326,13 +355,8 @@ export interface BatchBoardFilters {
sortOrder?: "ASC" | "DESC";
}
export interface BatchBoardListResponse {
items: BatchBoardSchedule[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/** Paginated batch-board list in the shared `{items, meta}` envelope. */
export type BatchBoardListResponse = PaginatedResponse<BatchBoardSchedule>;
export type BookingAllocationStatus =
| "NOT_ATTEMPTED"

View File

@@ -31,24 +31,31 @@ export function patchRuleEngineListRecord(
qc.setQueriesData<RuleEngineListResult<RuleEngineRecord>>(
{ queryKey: ["rule-engine", "list", resource] },
(old) => {
if (!old?.data?.length) return old;
const index = old.data.findIndex((row) => String(row.id) === updatedId);
if (!old?.items?.length) return old;
const index = old.items.findIndex((row) => String(row.id) === updatedId);
if (index === -1) return old;
const data = old.data.slice();
data[index] = { ...data[index], ...updated };
return { ...old, data };
const items = old.items.slice();
items[index] = { ...items[index], ...updated };
return { ...old, items };
},
);
}
/** Invalidate and refetch active rule-engine list queries for a resource. */
/**
* Invalidate and refetch active rule-engine list queries for a resource.
* Also covers the page-walked order/full lists (order-list key), which show
* the same rows and must refresh after any create/update/delete.
*/
export async function invalidateRuleEngineList(
qc: QueryClient,
resource: RuleEngineResourceSlug | string,
): Promise<void> {
const queryKey = ["rule-engine", "list", resource] as const;
await qc.invalidateQueries({ queryKey });
await qc.refetchQueries({ queryKey, type: "active" });
const listKey = ["rule-engine", "list", resource] as const;
const orderListKey = ["rule-engine", "order-list", resource] as const;
await qc.invalidateQueries({ queryKey: listKey });
await qc.invalidateQueries({ queryKey: orderListKey });
await qc.refetchQueries({ queryKey: listKey, type: "active" });
await qc.refetchQueries({ queryKey: orderListKey, type: "active" });
}
export function invalidateRuleEngineRoot(qc: QueryClient): Promise<void> {