mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 07:08:18 +00:00
enhance contract and booking services with server-side search and validation improvements
- Added parameter to and for server-side free-text search on contract reference, company name, and booking details. - Introduced new validation errors in for container clashes and space issues when creating bookings. - Implemented paginated dropdown settings retrieval in . - Updated to fetch active yards using a new method that handles pagination. - Enhanced with a method to fetch all records by walking through pages. - Refactored to support filtering and pagination in schedule listings. - Improved to return a paginated list of facilities. - Updated UI components in and to utilize debounced search inputs for better performance. - Added alerts in to inform users about booking constraints related to splits and capacity. - Enhanced to display notifications for split bookings and capacity usage.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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, " ");
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -489,8 +489,9 @@ export default function BatchBoardPage() {
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -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";
|
||||
@@ -52,7 +53,12 @@ import { 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,54 @@ 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 } }),
|
||||
);
|
||||
// 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 +209,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 +504,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 +526,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 +546,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 +566,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 +583,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 +606,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 +627,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 +645,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: filtered.length,
|
||||
totalCount: totalSchedules,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
@@ -648,13 +664,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 +691,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
<RuleEngineListFooter
|
||||
pagination={pagination}
|
||||
pageCount={pageCount}
|
||||
totalCount={filtered.length}
|
||||
totalCount={totalSchedules}
|
||||
itemLabel="schedules"
|
||||
onPaginationChange={setPagination}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -34,6 +34,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 +185,7 @@ 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.search) params.search = filter.search;
|
||||
}
|
||||
const response = await client.get<PaginatedBookings>(B.BASE, {
|
||||
params,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user