:Merge branch 'dev' of github.com:Tria-plc/edr-platform into uienhancement

Please enter a commit message to explain why this merge is necessary,
:qa!
This commit is contained in:
Hagernesh
2026-07-13 09:57:21 +00:00
631 changed files with 7373 additions and 156702 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";
@@ -126,6 +127,7 @@ import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import { HealthCheck } from "./features/health/HealthCheck";
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import { UserManagementRoutes } from "./user-management/route";
import SetPassword from "./shared/components/SetPassword";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -152,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",
@@ -655,6 +665,10 @@ const App = () => {
<Routes>
<Route path="/auth" element={<LoginPage />} />
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
<Route
path="um/set-password"
element={<SetPassword />}
/>
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
@@ -788,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

@@ -232,6 +232,15 @@ export default function BookingWindowSettingsModal({
</Alert>
) : (
<Stack gap="lg">
{!isExport ? (
<Alert variant="light" color="orange" icon={<Info size={16} />}>
These settings apply to every train on this route (same origin and
destination) departing the same day they all share one booking
window, so it opens, moves to document review, opens for payment,
and closes at the same time for all of them.
</Alert>
) : null}
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single first-come-first-served window: it

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,503 @@
import {
ActionIcon,
Box,
Card,
Group,
Select,
Stack,
Tabs,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import {
ArrowRight,
FileText,
Inbox,
RefreshCw,
Repeat,
Search,
User,
X,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import { PageContainer, PageHeader } from "@/components/page";
import {
toContractListRow,
type ContractListRow,
} from "@/features/contracts/mapContractListRow";
import { bookingsService } from "@/services/bookings.service";
import { contractsService } from "@/services/contracts.service";
import type { BookingDetail } from "@/types/booking";
import {
Badge,
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
/**
* Operations "Clearance Documents" hub — worklist for clearance-document
* review on contracts WITHOUT customs clearing (self-clearance):
* Contracts tab = contract-level review (one-time flow), General tab =
* per-booking review under GENERAL non-customs contracts. Rows deep-link to
* the existing review detail pages; search / status filter / pagination are
* all server-side.
*/
type HubTab = "contracts" | "general";
const PAGE_SIZE = 10;
/** Status filter options for the Contracts tab (values = `statuses` param). */
const CONTRACT_STATUS_OPTIONS = [
{
value: [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"ACTIVE_SHIPMENT_IN_PROGRESS",
"CONTRACT_CLOSED",
"CANCELLED",
].join(","),
label: "All statuses",
},
{ value: "AWAITING_CLEARANCE_DOCUMENTS", label: "Awaiting documents" },
{ value: "CLEARANCE_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY_FOR_BOOKING", label: "Ready for booking" },
{ value: "FULLY_EXECUTED,CONTRACT_ACTIVE", label: "Finalized" },
{
value: "ACTIVE_SHIPMENT_IN_PROGRESS,CONTRACT_CLOSED",
label: "In progress / closed",
},
{ value: "CANCELLED", label: "Cancelled" },
];
/** Status filter options for the General (per-booking) tab. */
const BOOKING_STATUS_OPTIONS = [
{
value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY",
label: "All statuses",
},
{ value: "AWAITING_DOCUMENTS", label: "Awaiting documents" },
{ value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY", label: "Clearance ready" },
];
export default function ClearanceDocumentsPage() {
const navigate = useNavigate();
const [hubTab, setHubTab] = useState<HubTab>("contracts");
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [contractStatuses, setContractStatuses] = useState(
CONTRACT_STATUS_OPTIONS[0].value,
);
const [bookingStatuses, setBookingStatuses] = useState(
BOOKING_STATUS_OPTIONS[0].value,
);
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
const search = debouncedQuery.trim() || undefined;
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: PAGE_SIZE });
}, [setPagination]);
const page = pagination.pageIndex + 1;
const contractsQuery = useQuery({
queryKey: [
"clearance-documents",
"contracts",
contractStatuses,
page,
search,
],
queryFn: () =>
contractsService.getOpsClearanceQueue({
page,
pageSize: PAGE_SIZE,
statuses: contractStatuses,
search,
}),
enabled: hubTab === "contracts",
placeholderData: keepPreviousData,
});
const generalQuery = useQuery({
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
queryFn: () =>
bookingsService.list({
statuses: bookingStatuses,
bookingType: "GENERAL_CONTRACT",
customsClearingEnabled: "false",
page,
pageSize: PAGE_SIZE,
search,
}),
enabled: hubTab === "general",
placeholderData: keepPreviousData,
});
const contractRows = useMemo(
() => (contractsQuery.data?.items ?? []).map(toContractListRow),
[contractsQuery.data?.items],
);
const bookingRows = generalQuery.data?.items ?? [];
const contractColumns: ColumnDef<ContractListRow>[] = useMemo(
() => [
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{c.customerLabel}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
{c.reference}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">
{c.destinationLabel}
</span>
</div>
<div className="flex gap-1.5">
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{c.tradeDirection}
</Badge>
<Badge
variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
>
{c.freightType}
</Badge>
</div>
</div>
);
},
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Kind</span>,
cell: ({ row }) => (
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
>
{row.original.contractKind === "GENERAL" ? (
<span className="inline-flex items-center gap-1">
<Repeat className="size-3" /> General
</span>
) : (
"One-time"
)}
</Badge>
),
},
{
id: "status",
size: 200,
minSize: 180,
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<ContractStatusBadge
status={row.original.status}
isRenewal={row.original.isRenewal}
/>
</div>
),
meta: {
headerClassName: "min-w-[11rem]",
cellClassName: "min-w-[11rem]",
},
},
],
[],
);
const bookingColumns: ColumnDef<BookingDetail>[] = useMemo(
() => [
{
id: "booking",
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const b = row.original;
const customer = b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—");
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{customer}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
{b.reference}
</p>
</div>
</div>
);
},
},
{
id: "contractRef",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => (
<Text size="sm">{row.original.contractReference ?? "—"}</Text>
),
},
{
id: "shipment",
header: () => <span className={bookingTable.headerCell}>Shipment</span>,
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex gap-1.5 py-1">
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{b.tradeDirection ?? "—"}
</Badge>
<Badge
variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
>
{b.freightType ?? "—"}
</Badge>
</div>
);
},
},
{
id: "status",
size: 200,
minSize: 180,
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<BookingStatusBadge status={row.original.status} />
</div>
),
meta: {
headerClassName: "min-w-[11rem]",
cellClassName: "min-w-[11rem]",
},
},
],
[],
);
const isContracts = hubTab === "contracts";
const activeQuery = isContracts ? contractsQuery : generalQuery;
const total = activeQuery.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const showEmpty =
!activeQuery.isLoading &&
!activeQuery.isError &&
(isContracts ? contractRows.length : bookingRows.length) === 0;
const tableStatus = activeQuery.isLoading
? "loading"
: activeQuery.isError
? "error"
: "success";
const statusOptions = isContracts
? CONTRACT_STATUS_OPTIONS
: BOOKING_STATUS_OPTIONS;
const statusValue = isContracts ? contractStatuses : bookingStatuses;
const setStatusValue = isContracts ? setContractStatuses : setBookingStatuses;
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Clearance Documents"
subtitle="Operations review of customer clearance documents for contracts without customs clearing."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
loading={activeQuery.isFetching}
onClick={() => void activeQuery.refetch()}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<Tabs
value={hubTab}
onChange={(v) => {
setHubTab((v as HubTab) ?? "contracts");
resetPage();
}}
>
<Tabs.List>
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
<Tabs.Tab value="general">General</Tabs.Tab>
</Tabs.List>
</Tabs>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder={
isContracts
? "Search reference or customer…"
: "Search booking, contract or customer…"
}
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
data={statusOptions}
value={statusValue}
onChange={(v) => {
setStatusValue(v ?? statusOptions[0].value);
resetPage();
}}
allowDeselect={false}
radius="lg"
w={220}
aria-label="Filter by status"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
{showEmpty ? (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">
No {isContracts ? "contracts" : "bookings"} match this view.
</Text>
</Stack>
) : (
<Box style={{ overflowX: "auto" }} w="100%">
{isContracts ? (
<DataTable
columns={contractColumns}
data={contractRows}
status={tableStatus}
onRowClick={(row) =>
navigate(`/dashboard/contracts/clearance/${row.id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
) : (
<DataTable
columns={bookingColumns}
data={bookingRows}
status={tableStatus}
onRowClick={(row) =>
navigate(`/dashboard/clearance/${row.id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
)}
</Box>
)}
</Stack>
</Card>
</Stack>
</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,16 @@ export const contractsService = {
},
// ── Path A self-clearance (Operations review) ──
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
getOpsClearanceQueue: async (filter?: {
page?: number;
pageSize?: number;
search?: string;
/** Comma-separated ops-clearance lifecycle statuses; omitted → under-review queue. */
statuses?: string;
}): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(
C.OPS_CLEARANCE_QUEUE,
{ params: filter },
);
const data = unwrap(response.data);
return {
@@ -467,8 +481,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

@@ -46,7 +46,8 @@ export const getOrganizations = async (
};
export const getMyAdminOrganizations = async (): Promise<AxiosResponse> => {
return axiosInstance.get("/organizations/my-admin-organizations", {
//my-admin-organizations
return axiosInstance.get("/organizations/with-admin-flag", {
headers: withHeaders(),
});
};

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