mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
Merge pull request #640 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -52,6 +52,7 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa
|
||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||
import ClearanceDocumentsPage from "./pages/contracts/ClearanceDocumentsPage";
|
||||
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||
@@ -153,6 +154,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <FileSignature />,
|
||||
permission: FREIGHT_PERMS.contracts.view,
|
||||
},
|
||||
// Operations hub: clearance-document review for contracts WITHOUT
|
||||
// customs clearing (contract-level for one-time, per-booking for general).
|
||||
{
|
||||
label: "Clearance Documents",
|
||||
href: "/dashboard/contracts/clearance-documents",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
@@ -793,6 +802,18 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Operations hub: clearance documents for non-customs contracts —
|
||||
Contracts tab (contract-level) + General tab (per-booking). */}
|
||||
<Route
|
||||
path="contracts/clearance-documents"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
|
||||
>
|
||||
<ClearanceDocumentsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* GL (Path B) contract clearance review hub */}
|
||||
<Route
|
||||
path="contracts/clearance"
|
||||
|
||||
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),
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Tabs,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
/**
|
||||
* Operations "Clearance Documents" hub — the worklist for clearance-document
|
||||
* review on contracts WITHOUT customs clearing (self-clearance / Path A):
|
||||
*
|
||||
* - Contracts tab: contracts whose clearance runs at contract level; rows open
|
||||
* the contract clearance detail where Operations approves + finalizes.
|
||||
* - General tab: booking instances under GENERAL non-customs contracts (those
|
||||
* clear per booking); rows open the booking clearance review page.
|
||||
*
|
||||
* The hub only lists — all review/approve/finalize actions live on the
|
||||
* existing detail pages it links to.
|
||||
*/
|
||||
|
||||
type HubTab = "contracts" | "general";
|
||||
type QueueTab = "queue" | "history";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/** Booking statuses that mean "docs awaiting review" / "review finished". */
|
||||
const BOOKING_QUEUE_STATUS = "DOCUMENTS_UNDER_REVIEW";
|
||||
const BOOKING_HISTORY_STATUS = "CLEARANCE_READY";
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleDateString(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function statusLabel(status?: string | null): string {
|
||||
return (status ?? "—").replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status?: string | null }) {
|
||||
const done =
|
||||
status === "CLEARANCE_READY" ||
|
||||
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||
status === "ACTIVE" ||
|
||||
status === "CONTRACT_ACTIVE" ||
|
||||
status === "FULLY_EXECUTED";
|
||||
return (
|
||||
<Badge variant="light" color={done ? "edr-green" : "yellow"} radius="sm">
|
||||
{statusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClearanceDocumentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [hubTab, setHubTab] = useState<HubTab>("contracts");
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>("queue");
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const search = debouncedQuery.trim() || undefined;
|
||||
|
||||
const contractsPager = usePagination({ pageSize: PAGE_SIZE });
|
||||
const generalPager = usePagination({ pageSize: PAGE_SIZE });
|
||||
|
||||
// Any search / queue-history / tab switch restarts both lists from page 1.
|
||||
useEffect(() => {
|
||||
contractsPager.setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
generalPager.setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedQuery, queueTab, hubTab]);
|
||||
|
||||
const isHistory = queueTab === "history";
|
||||
|
||||
const contractsQuery = useQuery({
|
||||
queryKey: [
|
||||
"clearance-documents",
|
||||
"contracts",
|
||||
queueTab,
|
||||
contractsPager.pagination.pageIndex,
|
||||
search,
|
||||
],
|
||||
queryFn: () => {
|
||||
const filter = {
|
||||
page: contractsPager.pagination.pageIndex + 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
search,
|
||||
};
|
||||
return isHistory
|
||||
? contractsService.getOpsClearanceHistory(filter)
|
||||
: contractsService.getOpsClearanceQueue(filter);
|
||||
},
|
||||
enabled: hubTab === "contracts",
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const generalQuery = useQuery({
|
||||
queryKey: [
|
||||
"clearance-documents",
|
||||
"general",
|
||||
queueTab,
|
||||
generalPager.pagination.pageIndex,
|
||||
search,
|
||||
],
|
||||
queryFn: () =>
|
||||
bookingsService.list({
|
||||
status: isHistory ? BOOKING_HISTORY_STATUS : BOOKING_QUEUE_STATUS,
|
||||
bookingType: "GENERAL_CONTRACT",
|
||||
customsClearingEnabled: "false",
|
||||
page: generalPager.pagination.pageIndex + 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
search,
|
||||
}),
|
||||
enabled: hubTab === "general",
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const contractColumns = useMemo(
|
||||
(): ColumnDef<Freight.IContract, unknown>[] => [
|
||||
{
|
||||
header: "Reference",
|
||||
accessorKey: "reference",
|
||||
},
|
||||
{
|
||||
header: "Customer",
|
||||
cell: ({ row }) => row.original.company?.name ?? "—",
|
||||
},
|
||||
{
|
||||
header: "Kind",
|
||||
cell: ({ row }) => statusLabel(row.original.contractKind),
|
||||
},
|
||||
{
|
||||
header: "Direction",
|
||||
cell: ({ row }) => statusLabel(row.original.tradeDirection),
|
||||
},
|
||||
{
|
||||
header: "Freight",
|
||||
cell: ({ row }) => statusLabel(row.original.freightType),
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
header: "Created",
|
||||
cell: ({ row }) => formatDate(row.original.createdAt),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const bookingColumns = useMemo(
|
||||
(): ColumnDef<BookingDetail, unknown>[] => [
|
||||
{
|
||||
header: "Reference",
|
||||
accessorKey: "reference",
|
||||
},
|
||||
{
|
||||
header: "Customer",
|
||||
cell: ({ row }) =>
|
||||
row.original.isGovernment
|
||||
? (row.original.governmentInstitution ?? "Government")
|
||||
: (row.original.company?.name ?? "—"),
|
||||
},
|
||||
{
|
||||
header: "Contract",
|
||||
cell: ({ row }) => row.original.contractReference ?? "—",
|
||||
},
|
||||
{
|
||||
header: "Direction",
|
||||
cell: ({ row }) => statusLabel(row.original.tradeDirection),
|
||||
},
|
||||
{
|
||||
header: "Freight",
|
||||
cell: ({ row }) => statusLabel(row.original.freightType),
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const activeQuery = hubTab === "contracts" ? contractsQuery : generalQuery;
|
||||
const total = activeQuery.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const tableStatus = activeQuery.isLoading
|
||||
? "loading"
|
||||
: activeQuery.isError
|
||||
? "error"
|
||||
: "success";
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Clearance Documents"
|
||||
subtitle="Operations review of customer clearance documents for contracts without customs clearing — contract-level (one-time) and per-booking (general)."
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Tabs
|
||||
value={hubTab}
|
||||
onChange={(v) => setHubTab((v as HubTab) ?? "contracts")}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="contracts">Contracts</Tabs.Tab>
|
||||
<Tabs.Tab value="general">General</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Group gap="sm">
|
||||
<SegmentedControl
|
||||
value={queueTab}
|
||||
onChange={(v) => setQueueTab(v as QueueTab)}
|
||||
data={[
|
||||
{ value: "queue", label: "Queue" },
|
||||
{ value: "history", label: "History" },
|
||||
]}
|
||||
size="xs"
|
||||
/>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
placeholder={
|
||||
hubTab === "contracts"
|
||||
? "Search reference or customer…"
|
||||
: "Search booking, customer or contract…"
|
||||
}
|
||||
leftSection={<Search size={14} />}
|
||||
w={260}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{hubTab === "contracts" ? (
|
||||
<DataTable<Freight.IContract, unknown>
|
||||
columns={contractColumns}
|
||||
data={contractsQuery.data?.items ?? []}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/dashboard/contracts/clearance/${row.id}`)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: contractsPager.pagination.pageIndex,
|
||||
pageSize: PAGE_SIZE,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination: contractsPager.pagination },
|
||||
onPaginationChange: contractsPager.setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
) : (
|
||||
<DataTable<BookingDetail, unknown>
|
||||
columns={bookingColumns}
|
||||
data={generalQuery.data?.items ?? []}
|
||||
status={tableStatus}
|
||||
onRowClick={(row) => navigate(`/dashboard/clearance/${row.id}`)}
|
||||
pagination={{
|
||||
pageIndex: generalPager.pagination.pageIndex,
|
||||
pageSize: PAGE_SIZE,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination: generalPager.pagination },
|
||||
onPaginationChange: generalPager.setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -450,9 +457,14 @@ export const contractsService = {
|
||||
},
|
||||
|
||||
// ── Path A self-clearance (Operations review) ──
|
||||
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
|
||||
getOpsClearanceQueue: async (filter?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
}): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(
|
||||
C.OPS_CLEARANCE_QUEUE,
|
||||
{ params: filter },
|
||||
);
|
||||
const data = unwrap(response.data);
|
||||
return {
|
||||
@@ -467,8 +479,15 @@ export const contractsService = {
|
||||
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
|
||||
},
|
||||
|
||||
getOpsClearanceHistory: async (): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(C.OPS_CLEARANCE_HISTORY);
|
||||
getOpsClearanceHistory: async (filter?: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
search?: string;
|
||||
}): Promise<PaginatedContracts> => {
|
||||
const response = await client.get<PaginatedContracts>(
|
||||
C.OPS_CLEARANCE_HISTORY,
|
||||
{ params: filter },
|
||||
);
|
||||
const data = unwrap(response.data);
|
||||
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
|
||||
},
|
||||
|
||||
@@ -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