mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge pull request #640 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user