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

This commit is contained in:
Marshal
2026-08-15 10:12:59 +00:00
87 changed files with 4750 additions and 1175 deletions

View File

@@ -1,44 +1,32 @@
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
import {
ActionIcon,
Box,
Button,
Card,
Checkbox,
Collapse,
Group,
Modal,
MultiSelect,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
ArrowRight,
Calendar,
CheckCircle2,
Clock,
FilterX,
LayoutList,
Package,
Plus,
RefreshCw,
Search,
User,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { FilterToggle } from "@/components/common/FilterToggle";
import { formatDate, humanize } from "@/lib/format";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
@@ -63,7 +51,6 @@ import {
Badge,
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
@@ -104,61 +91,11 @@ const OWNERSHIP_OPTIONS = [
{ value: "false", label: "Private" },
];
/** Local start-of-day → ISO, for inclusive "from" date filters. */
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
/** Local end-of-day → ISO, for inclusive "to" date filters. */
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
export default function BookingRequestsPage() {
const navigate = useNavigate();
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
// the header's document-review alarm opens exactly the undecided requests it
// is counting down for. Read once as the initial state so staff can then
// change the filters like any other visit.
const [searchParams] = useSearchParams();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// Booking kind is a filter now — one list holds both kinds (null = "all").
const [kindFilter, setKindFilter] = useState<BookingKind | null>(null);
// Filter controls (empty/null = "all").
const paramStatuses = searchParams.get("statuses") ?? "";
const paramDirection = searchParams.get("tradeDirection");
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
paramStatuses.split(",").filter(Boolean),
);
const { filterOptions } = useMyTradeAccess();
const [directionFilter, setDirectionFilter] = useState<string | null>(
paramDirection,
);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [originYardFilter, setOriginYardFilter] = useState<string | null>(null);
const [destinationYardFilter, setDestinationYardFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
// Direction is the only deep-linkable advanced filter — open the panel so a
// deep link never hides its own filter.
const [showAdvanced, setShowAdvanced] = useState(() =>
Boolean(paramDirection),
);
const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]);
// Paid bookings with no train attached (staff removed them or a sweep
// detached them) — the queue the per-row Allocate action works through.
const [paidUnallocated, setPaidUnallocated] = useState(false);
const [allocatingId, setAllocatingId] = useState<string | null>(null);
const [otherDayModal, setOtherDayModal] = useState<{
booking: BookingListRow;
@@ -173,77 +110,6 @@ export default function BookingRequestsPage() {
}, 400);
}, []);
// Follow the URL when a deep link arrives while the page is already open
// (clicking the header alarm from this very list). Same-value writes are
// dropped so a manual filter change is never undone.
useEffect(() => {
const next = paramStatuses.split(",").filter(Boolean);
setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next));
setDirectionFilter(paramDirection);
if (paramDirection) setShowAdvanced(true);
}, [paramStatuses, paramDirection]);
const filter: BookingListFilter = useMemo(() => {
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
// React Query cache key per kind selection ("ALL" when unfiltered).
tab: kindFilter ?? "ALL",
...(kindFilter ? { bookingType: kindFilter } : {}),
// 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 } : {}),
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
// Wins over the payment-status select — the queue is by definition PAID.
...(paidUnallocated
? { paymentStatus: "PAID", assignedToSchedule: "false" as const }
: {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(originYardFilter ? { originYardId: originYardFilter } : {}),
...(destinationYardFilter
? { destinationYardId: destinationYardFilter }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
...(scheduledFrom ? { scheduledFrom: startOfDayIso(scheduledFrom) } : {}),
...(scheduledTo ? { scheduledTo: endOfDayIso(scheduledTo) } : {}),
};
}, [
pagination.pageIndex,
pagination.pageSize,
kindFilter,
debouncedQuery,
statusFilter,
directionFilter,
freightTypeFilter,
paymentStatusFilter,
paidUnallocated,
ownershipFilter,
originYardFilter,
destinationYardFilter,
createdFrom,
createdTo,
scheduledFrom,
scheduledTo,
]);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
const primaryAllocateId = allocateIds[0];
const { data: allocateBooking } = useBookingDetail(
allocateOpen ? primaryAllocateId : undefined,
);
const {
data: summary,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useBookingListSummary(filter);
// Yard options for the origin/destination filters (shared routes reference list).
const { data: yardRefs } = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
@@ -257,43 +123,72 @@ export default function BookingRequestsPage() {
[yardRefs],
);
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
// the header's document-review alarm opens exactly the undecided requests
// it is counting down for. No sync effect needed any more: controls.values
// reads live off the URL every render, so a link opened while this page is
// already mounted just works, and every filter — direction included —
// auto-pins its own pill the moment it has a value (FilterBar's `secondary`
// split), so a deep link can never land behind "More filters" unseen.
const bookingFilterDefs: FilterDef[] = useMemo(
() => [
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{
key: "tradeDirection", label: "Direction", type: "enum", multiple: false,
options: filterOptions(TRADE_DIRECTION_OPTIONS),
},
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
{ key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true },
{
// Wins over the `paymentStatus` filter above — the queue is by
// definition PAID — because it's later in this array: toApiParams
// merges defs in order, so a later toParams overwrites an earlier one.
key: "paidUnallocated", label: "Allocation", type: "boolean", secondary: true,
trueLabel: "Paid, not allocated",
toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}),
},
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
{
key: "route", label: "Route", type: "route", options: yardOptions,
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
},
{
key: "created", label: "Created", type: "date", secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("createdFrom", "createdTo"),
},
{
key: "scheduled", label: "Scheduled", type: "date", secondary: true,
operators: ["between", "before", "after"],
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
},
],
[filterOptions, yardOptions],
);
const activeFilterCount =
(kindFilter ? 1 : 0) +
(statusFilter.length ? 1 : 0) +
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
(paymentStatusFilter ? 1 : 0) +
(paidUnallocated ? 1 : 0) +
(ownershipFilter ? 1 : 0) +
(originYardFilter ? 1 : 0) +
(destinationYardFilter ? 1 : 0) +
(createdFrom || createdTo ? 1 : 0) +
(scheduledFrom || scheduledTo ? 1 : 0);
const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 });
// Badge on the advanced-filters toggle — active filters hidden behind it.
const advancedFilterCount =
activeFilterCount - (kindFilter ? 1 : 0) - (statusFilter.length ? 1 : 0);
const filter: BookingListFilter = useMemo(
() => ({
...(controls.params as unknown as BookingListFilter),
// React Query cache key per kind selection ("ALL" when unfiltered) —
// kept as a param the API ignores, matching the pre-migration cache key.
tab: (controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
}),
[controls.params, controls.values.bookingType],
);
const clearFilters = useCallback(() => {
setKindFilter(null);
setStatusFilter([]);
setDirectionFilter(null);
setFreightTypeFilter(null);
setPaymentStatusFilter(null);
setPaidUnallocated(false);
setOwnershipFilter(null);
setOriginYardFilter(null);
setDestinationYardFilter(null);
setCreatedFrom(null);
setCreatedTo(null);
setScheduledFrom(null);
setScheduledTo(null);
resetPage();
}, [resetPage]);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
const primaryAllocateId = allocateIds[0];
const { data: allocateBooking } = useBookingDetail(
allocateOpen ? primaryAllocateId : undefined,
);
const {
data: summary,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useBookingListSummary(filter);
// Search is applied server-side (via the `search` filter param) — no
// client-side filtering here.
@@ -303,8 +198,7 @@ export default function BookingRequestsPage() {
);
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const hasSearch = query.trim().length > 0;
const hasSearch = controls.searchText.trim().length > 0;
const showEmpty = !isLoading && !isError && rows.length === 0;
const metrics = summary?.metrics;
@@ -585,195 +479,13 @@ export default function BookingRequestsPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search booking, contract or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
placeholder="All booking types"
data={BOOKING_KIND_OPTIONS}
value={kindFilter}
onChange={(v) => {
setKindFilter((v as BookingKind | null) ?? null);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 190 }}
/>
<MultiSelect
placeholder={statusFilter.length ? undefined : "All statuses"}
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 220 }}
/>
<FilterToggle
count={advancedFilterCount}
expanded={showAdvanced}
onClick={() => setShowAdvanced((v) => !v)}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
<Collapse expanded={showAdvanced}>
<Group gap="sm" wrap="wrap">
<Select
placeholder="All origins"
data={yardOptions}
value={originYardFilter}
onChange={(v) => {
setOriginYardFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 180 }}
/>
<Select
placeholder="All destinations"
data={yardOptions}
value={destinationYardFilter}
onChange={(v) => {
setDestinationYardFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 180 }}
/>
<Select
placeholder="All directions"
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 150 }}
/>
<Select
placeholder="All freight types"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 150 }}
/>
<Select
placeholder="All payment statuses"
data={PAYMENT_STATUS_OPTIONS}
value={paymentStatusFilter}
onChange={(v) => {
setPaymentStatusFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 180 }}
/>
<Checkbox
label="Paid, not allocated"
checked={paidUnallocated}
onChange={(e) => {
setPaidUnallocated(e.currentTarget.checked);
resetPage();
}}
radius="sm"
style={{ alignSelf: "center" }}
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
/>
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
setCreatedFrom(from ? new Date(from) : null);
setCreatedTo(to ? new Date(to) : null);
resetPage();
}}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 220 }}
/>
<DatePickerInput
type="range"
placeholder="Scheduled date range"
value={[scheduledFrom, scheduledTo]}
onChange={([from, to]) => {
setScheduledFrom(from ? new Date(from) : null);
setScheduledTo(to ? new Date(to) : null);
resetPage();
}}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 230 }}
/>
</Group>
</Collapse>
</Stack>
<Box px="md" pt="sm" pb="xs" w="100%">
<FilterBar
defs={bookingFilterDefs}
controls={controls}
searchPlaceholder="Search booking, contract or customer…"
viewId="booking-requests"
/>
</Box>
{showEmpty ? (
@@ -791,18 +503,7 @@ export default function BookingRequestsPage() {
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={handleRowClick}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>

View File

@@ -3,20 +3,11 @@ import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
import {
ActionIcon,
Box,
Button,
Card,
Collapse,
Group,
MultiSelect,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
ArrowRight,
@@ -24,20 +15,18 @@ import {
CheckCircle2,
Clock,
FileText,
FilterX,
Inbox,
LayoutList,
RefreshCw,
Repeat,
Search,
User,
X,
} from "lucide-react";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useCallback, useMemo, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import { FilterToggle } from "@/components/common/FilterToggle";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import {
@@ -61,9 +50,9 @@ import {
Badge,
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { FilterBar, dateRangeParams, useFilters, type FilterDef } from "@/components/filters";
/** Every filterable status — the pill tabs are gone, so the select carries them all. */
const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map(
@@ -112,99 +101,91 @@ const COLUMN_META = {
cellClassName: "whitespace-normal break-words align-top",
};
/** Local start-of-day → ISO, for inclusive "from" date filters. */
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
/** Local end-of-day → ISO, for inclusive "to" date filters. */
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
export default function ContractRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// Filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const { filterOptions } = useMyTradeAccess();
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
null,
// Yard options for the route filter (shared routes reference list, same
// query BookingRequestsPage uses).
const { data: yardRefs } = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
);
const yardOptions = useMemo(
() => (yardRefs ?? []).map((y) => ({ value: y.id, label: y.label ?? y.code })),
[yardRefs],
);
const [kindFilter, setKindFilter] = useState<string | null>(null);
const [currencyFilter, setCurrencyFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const [sort, setSort] = useState<string>("createdAt:DESC");
// All filters start empty (no URL params on this page), so collapsed is safe.
const [showAdvanced, setShowAdvanced] = useState(false);
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
// Static shape only (no facet counts) — this is what useFilters needs to
// parse the URL and build API params. Counts are attached separately below,
// for rendering only, once the summary query (which itself depends on
// these params) has resolved.
const filterDefs: FilterDef[] = useMemo(
() => [
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
{
key: "contractKind",
label: "Kind",
type: "enum",
multiple: false,
options: CONTRACT_KIND_OPTIONS,
},
{
key: "tradeDirection",
label: "Direction",
type: "enum",
multiple: false,
options: filterOptions(TRADE_DIRECTION_OPTIONS),
},
{
key: "freightType",
label: "Freight",
type: "enum",
multiple: false,
options: FREIGHT_TYPE_OPTIONS,
},
{
key: "paymentCurrency",
label: "Currency",
type: "enum",
multiple: false,
options: CURRENCY_OPTIONS,
secondary: true,
},
{
key: "created",
label: "Created",
type: "date",
secondary: true,
// Before/after are safe to expose: the repository applies
// createdFrom/createdTo independently, so a single-sided bound
// already works server-side.
operators: ["between", "before", "after"],
toParams: dateRangeParams("createdFrom", "createdTo"),
},
{
key: "route",
label: "Route",
type: "route",
options: yardOptions,
toParams: ({ v }) => ({ originYardId: v[0], destinationYardId: v[1] }),
},
],
[filterOptions, yardOptions],
);
const filter: ContractListFilter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy,
sortOrder,
const controls = useFilters(filterDefs, {
defaultSort: "createdAt:DESC",
pageSize: 10,
});
const filter: ContractListFilter = useMemo(
() => ({
...(controls.params as unknown as ContractListFilter),
// Kept as the React Query cache-key discriminator (tabs themselves are gone).
tab: "all",
// Server-side free-text search (contract reference, customer name).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(kindFilter ? { contractKind: kindFilter } : {}),
...(currencyFilter ? { paymentCurrency: currencyFilter } : {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
};
}, [
pagination.pageIndex,
pagination.pageSize,
debouncedQuery,
statusFilter,
directionFilter,
freightTypeFilter,
kindFilter,
currencyFilter,
createdFrom,
createdTo,
sort,
]);
const activeFilterCount =
(statusFilter.length ? 1 : 0) +
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
(kindFilter ? 1 : 0) +
(currencyFilter ? 1 : 0) +
(createdFrom || createdTo ? 1 : 0);
// Badge on the advanced-filters toggle — active filters hidden behind it.
const advancedFilterCount =
activeFilterCount - (statusFilter.length ? 1 : 0) - (kindFilter ? 1 : 0);
const clearFilters = useCallback(() => {
setStatusFilter([]);
setDirectionFilter(null);
setFreightTypeFilter(null);
setKindFilter(null);
setCurrencyFilter(null);
setCreatedFrom(null);
setCreatedTo(null);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
}),
[controls.params],
);
const { data, isLoading, isError, refetch, isFetching } =
useContractList(filter);
@@ -214,13 +195,27 @@ export default function ContractRequestsPage() {
refetch: refetchSummary,
} = useContractListSummary(filter);
const statusCounts = useMemo(
() => Object.fromEntries((summary?.facets?.status ?? []).map((b) => [b.value, b.count])),
[summary?.facets],
);
// filterDefs + counts, for the bar to render. Kept separate from filterDefs
// itself so the URL-parsing hook above never has to wait on this query.
const defs: FilterDef[] = useMemo(
() =>
filterDefs.map((d) =>
d.key === "statuses" && d.type === "enum" ? { ...d, counts: statusCounts } : d,
),
[filterDefs, statusCounts],
);
const rows = useMemo(
() => (data?.items ?? []).map(toContractListRow),
[data?.items],
);
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const showEmpty = !isLoading && !isError && rows.length === 0;
const metrics = summary?.metrics;
@@ -450,153 +445,14 @@ export default function ContractRequestsPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm">
<Group gap="sm" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
data={SORT_OPTIONS}
value={sort}
onChange={(v) => {
setSort(v ?? "createdAt:DESC");
resetPage();
}}
allowDeselect={false}
radius="lg"
style={{ minWidth: 170 }}
aria-label="Sort contracts"
/>
<MultiSelect
placeholder={
statusFilter.length ? undefined : "All statuses"
}
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 220 }}
aria-label="Filter by status"
/>
<Select
placeholder="All kinds"
data={CONTRACT_KIND_OPTIONS}
value={kindFilter}
onChange={(v) => {
setKindFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
aria-label="Filter by contract kind"
/>
<FilterToggle
count={advancedFilterCount}
expanded={showAdvanced}
onClick={() => setShowAdvanced((v) => !v)}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
<Collapse expanded={showAdvanced}>
<Group gap="sm" wrap="wrap">
<Select
placeholder="All directions"
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 150 }}
aria-label="Filter by trade direction"
/>
<Select
placeholder="All freight types"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="All currencies"
data={CURRENCY_OPTIONS}
value={currencyFilter}
onChange={(v) => {
setCurrencyFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by payment currency"
/>
<DatePickerInput
type="range"
placeholder="Created date range"
value={[createdFrom, createdTo]}
onChange={([from, to]) => {
setCreatedFrom(from ? new Date(from) : null);
setCreatedTo(to ? new Date(to) : null);
resetPage();
}}
presets={getDateRangePresets()}
clearable
radius="lg"
style={{ minWidth: 220 }}
aria-label="Created date range"
/>
</Group>
</Collapse>
</Stack>
<Box px="md" pt="sm" pb="xs" w="100%">
<FilterBar
defs={defs}
controls={controls}
searchPlaceholder="Search reference or customer…"
sortOptions={SORT_OPTIONS}
viewId="contract-requests"
/>
</Box>
{showEmpty ? (
@@ -615,18 +471,7 @@ export default function ContractRequestsPage() {
isLoading ? "loading" : isError ? "error" : "success"
}
onRowClick={handleRowClick}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
{...controls.tableProps(total)}
// table-fixed makes the per-column widths stick; without
// it auto-layout re-widens columns once cells wrap.
containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]"

View File

@@ -5,13 +5,10 @@ import {
Card,
Group,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import {
Building2,
@@ -22,10 +19,8 @@ import {
Mail,
Phone,
RefreshCw,
Search,
ShieldOff,
Users,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -40,12 +35,8 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Company, CompanyStatus } from "@/types/customer";
import { isOnboardingDraft } from "@/types/customer";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
import { FilterBar, useFilters, type FilterDef } from "@/components/filters";
/**
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
@@ -92,28 +83,29 @@ const SORT_OPTIONS = [
{ value: "name:DESC", label: "Name (ZA)" },
] as const;
/** No filter pills — search/sort/page are the only real filter dimensions;
* `view` below is a tab (mutually exclusive, navigational), not a filter. */
const NO_FILTER_DEFS: FilterDef[] = [];
export default function CustomersPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [view, setView] = useState<CustomerView>("all");
const [sort, setSort] = useState<string>("review:DESC");
const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 });
const filter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [
const [sortBy, sortOrder] = controls.sort.split(":") as [
"review" | "name" | "createdAt" | "updatedAt",
"ASC" | "DESC",
];
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
page: controls.page,
pageSize: controls.pageSize,
search: String(controls.params.search ?? ""),
sortBy,
sortOrder,
...VIEW_FILTERS[view],
};
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
}, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]);
const { data: stats } = useQuery(
api.customers.stats.queryOptions({ input: {} }),
@@ -125,7 +117,6 @@ export default function CustomersPage() {
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<Company>[] = useMemo(
() => [
@@ -295,35 +286,24 @@ export default function CustomersPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by company, TIN, email or profile reference…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<FilterBar
defs={NO_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search by company, TIN, email or profile reference…"
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
viewId="customers"
>
<SegmentedControl
size="sm"
radius="md"
value={view}
onChange={(v) => {
// `view` lives outside useFilters (it's a tab, not a
// filter pill), so switching it needs its own page reset —
// the same "stranded on page 5" hazard useFilters guards
// against for its own filters.
setView(v as CustomerView);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
controls.setPage(1);
}}
data={[
{ label: "All", value: "all" },
@@ -333,21 +313,7 @@ export default function CustomersPage() {
{ label: "Active", value: "active" },
]}
/>
<Select
size="sm"
radius="md"
w={160}
allowDeselect={false}
aria-label="Sort customers"
value={sort}
onChange={(v) => {
if (!v) return;
setSort(v);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={SORT_OPTIONS.map((o) => ({ ...o }))}
/>
</Group>
</FilterBar>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
@@ -358,7 +324,7 @@ export default function CustomersPage() {
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
emptyMessage={
debouncedQuery
controls.searchText
? "No companies match your search."
: "No companies yet."
}
@@ -370,18 +336,7 @@ export default function CustomersPage() {
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
{...controls.tableProps(total)}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>

View File

@@ -18,11 +18,16 @@ import {
} from "@mantine/core";
import { Plus, AlertTriangle } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useToast } from "@/hooks/use-toast";
import {
complianceService,
@@ -52,6 +57,8 @@ const statusColor = (status: ComplianceRecord["status"]) => {
const formatDate = (value?: string | null) =>
value ? new Date(value).toLocaleDateString() : "—";
const COMPLIANCE_FILTER_DEFS: FilterDef[] = [{ key: "expiryDate", label: "Expiry", type: "date" }];
const emptyForm = {
vehicleId: "",
type: "INSPECTION" as ComplianceType,
@@ -91,10 +98,18 @@ export default function CompliancePage() {
},
});
const controls = useListControls(records as ComplianceRecord[], {
searchKeys: ["type", "status", "documentNumber"],
dateKey: "expiryDate",
});
const controls = useFilters(COMPLIANCE_FILTER_DEFS, { pageSize: 10 });
const filteredRecords = applyClientFilters(
records as ComplianceRecord[],
COMPLIANCE_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ["type", "status", "documentNumber"] },
);
const pagedRecords = filteredRecords.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
const createMutation = useMutation({
mutationFn: async (data: typeof formData) => {
@@ -220,17 +235,11 @@ export default function CompliancePage() {
Compliance Records
</Title>
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={COMPLIANCE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search type, status, document no…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Expiry"
hasFilters={controls.hasFilters}
onReset={controls.reset}
viewId="fleet-compliance"
/>
<Table striped highlightOnHover>
<Table.Thead>
@@ -261,7 +270,7 @@ export default function CompliancePage() {
</Table.Td>
</Table.Tr>
) : null}
{controls.pagedRows.map((record) => (
{pagedRecords.map((record) => (
<Table.Tr key={record.id}>
<Table.Td>{vehicleLabel(record)}</Table.Td>
<Table.Td>
@@ -282,11 +291,8 @@ export default function CompliancePage() {
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="records"
onPaginationChange={controls.setPagination}
{...toRuleEngineFooterProps(controls, filteredRecords.length)}
/>
</Card>

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { Edit, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { Edit, Eye, Plus, Trash2 } from 'lucide-react';
import { FormEvent, ReactNode, useMemo, useState } from 'react';
import { api } from '@/services/api';
@@ -44,6 +44,13 @@ import type { Train } from '@/services/trains.service';
import type { WagonType } from '@/services/wagon-types.service';
import type { Wagon } from '@/services/wagon.service';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@edr/ui-common';
import {
applyClientFilters,
FilterBar,
useFilters,
type FilterDef,
type FilterOption,
} from '@/components/filters';
type FormValue = string | number | boolean | string[];
@@ -86,6 +93,8 @@ type FleetCrudPageProps<T extends { id: string }> = {
hideViewAction?: boolean;
/** Optional custom actions rendered before the view/edit/delete buttons in each row. */
rowActions?: (item: T) => React.ReactNode;
/** Enables the Status filter pill; the item's `status` field is matched against these. */
statusOptions?: FilterOption[];
};
const normalizePayload = (values: Record<string, FormValue>) =>
@@ -172,9 +181,16 @@ function FleetCrudPage<T extends { id: string }>({
removeSuccessMessage,
hideViewAction = false,
rowActions,
statusOptions,
}: FleetCrudPageProps<T>) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const filterDefs: FilterDef[] = useMemo(
() =>
statusOptions
? [{ key: 'status', label: 'Status', type: 'enum', multiple: false, options: statusOptions }]
: [],
[statusOptions],
);
const controls = useFilters(filterDefs, { pageSize: 10 });
const [sortKey, setSortKey] = useState<string>('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
@@ -184,11 +200,13 @@ function FleetCrudPage<T extends { id: string }>({
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const { toast } = useToast();
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return data ?? [];
return (data ?? []).filter((item) => searchText(item).toLowerCase().includes(query));
}, [data, search, searchText]);
const filtered = useMemo(
() =>
applyClientFilters(data ?? [], filterDefs, controls.values, controls.searchText, {
searchValue: searchText,
}),
[data, filterDefs, controls.values, controls.searchText, searchText],
);
const sorted = useMemo(() => {
if (!sortKey) return filtered;
return [...filtered].sort((a, b) => {
@@ -198,12 +216,13 @@ function FleetCrudPage<T extends { id: string }>({
return sortDirection === 'asc' ? result : -result;
});
}, [filtered, sortDirection, sortKey]);
const pageSize = 10;
const pageSize = controls.pageSize;
const page = controls.page;
const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const toggleSort = (key: string) => {
setPage(1);
controls.setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
@@ -298,18 +317,11 @@ function FleetCrudPage<T extends { id: string }>({
</Button>
</div>
<div className="flex max-w-md items-center gap-2 rounded-md border bg-background px-3">
<Search className="size-4 text-muted-foreground" />
<Input
className="border-0 px-0 shadow-none focus-visible:ring-0"
placeholder={`Search ${title.toLowerCase()}`}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
/>
</div>
<FilterBar
defs={filterDefs}
controls={controls}
searchPlaceholder={`Search ${title.toLowerCase()}`}
/>
<div className="overflow-hidden rounded-lg border bg-card">
<Table>
@@ -379,10 +391,10 @@ function FleetCrudPage<T extends { id: string }>({
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of {sorted.length}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => setPage((current) => current - 1)}>
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => controls.setPage(page - 1)}>
Previous
</Button>
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => setPage((current) => current + 1)}>
<Button variant="outline" size="sm" disabled={page === pageCount} onClick={() => controls.setPage(page + 1)}>
Next
</Button>
</div>
@@ -487,6 +499,47 @@ const statusBadge = (status?: string) => <Badge variant="outline">{status ?? '-'
const optionLabel = (options: { value: string; label: string }[], value?: string | null) =>
options.find((option) => option.value === value)?.label ?? value ?? '-';
const TRAIN_STATUS_OPTIONS: FilterOption[] = [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'SCHEDULED', label: 'Scheduled' },
{ value: 'IN_SERVICE', label: 'In service' },
{ value: 'UNDER_MAINTENANCE', label: 'Under maintenance' },
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
{ value: 'DEACTIVATED', label: 'Deactivated' },
];
const WAGON_STATUS_OPTIONS: FilterOption[] = [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'IMPORT_READY', label: 'Import ready' },
{ value: 'EXPORT_READY', label: 'Export ready' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'DETAINED', label: 'Detained' },
];
const CONTAINER_STATUS_OPTIONS: FilterOption[] = [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'IN_TRANSIT', label: 'In transit' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'DAMAGED', label: 'Damaged' },
];
const CARGO_STATUS_OPTIONS: FilterOption[] = [
{ value: 'PENDING', label: 'Pending' },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'IN_TRANSIT', label: 'In transit' },
{ value: 'DELIVERED', label: 'Delivered' },
{ value: 'UNLOADED', label: 'Unloaded' },
];
const LOCOMOTIVE_STATUS_OPTIONS: FilterOption[] = [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
];
export function TrainMasterDataPage() {
const query = useQuery(api.trains.list.queryOptions());
return (
@@ -499,6 +552,7 @@ export function TrainMasterDataPage() {
create={useMutation(api.trains.create.mutationOptions())}
update={useMutation(api.trains.update.mutationOptions())}
remove={useMutation(api.trains.remove.mutationOptions())}
statusOptions={TRAIN_STATUS_OPTIONS}
searchText={(train) => [train.code, train.trainNumber, train.trainName, train.status].join(' ')}
columns={[
{ key: 'code', label: 'Code' },
@@ -522,14 +576,17 @@ export function TrainMasterDataPage() {
);
}
const WAGON_TYPE_FILTER_DEFS: FilterDef[] = [
{ key: 'isActive', label: 'Status', type: 'boolean', trueLabel: 'Active', falseLabel: 'Inactive' },
];
export function WagonTypesCrudPage() {
const query = useQuery(api.wagonTypes.list.queryOptions());
const create = useMutation(api.wagonTypes.create.mutationOptions());
const update = useMutation(api.wagonTypes.update.mutationOptions());
const remove = useMutation(api.wagonTypes.remove.mutationOptions());
const { toast } = useToast();
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const controls = useFilters(WAGON_TYPE_FILTER_DEFS, { pageSize: 10 });
const [sortKey, setSortKey] = useState<keyof WagonType>('code');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [formOpen, setFormOpen] = useState(false);
@@ -546,18 +603,15 @@ export function WagonTypesCrudPage() {
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const pageSize = 10;
const filtered = useMemo(() => {
const queryText = search.trim().toLowerCase();
const rows = query.data ?? [];
if (!queryText) return rows;
return rows.filter((type) =>
[type.code, type.name, type.supportedLoadTypes?.join(' '), type.isActive ? 'active' : 'inactive']
.join(' ')
.toLowerCase()
.includes(queryText),
);
}, [query.data, search]);
const pageSize = controls.pageSize;
const page = controls.page;
const filtered = useMemo(
() =>
applyClientFilters(query.data ?? [], WAGON_TYPE_FILTER_DEFS, controls.values, controls.searchText, {
searchValue: (type) => [type.code, type.name, type.supportedLoadTypes?.join(' ')].join(' '),
}),
[query.data, controls.values, controls.searchText],
);
const sorted = useMemo(() => {
return [...filtered].sort((left, right) => {
@@ -573,7 +627,7 @@ export function WagonTypesCrudPage() {
const isSaving = create.isPending || update.isPending;
const toggleSort = (key: keyof WagonType) => {
setPage(1);
controls.setPage(1);
if (sortKey === key) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
return;
@@ -689,16 +743,7 @@ export function WagonTypesCrudPage() {
</MantineButton>
</Group>
<TextInput
maw={420}
leftSection={<Search size={16} />}
placeholder="Search wagon types"
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<FilterBar defs={WAGON_TYPE_FILTER_DEFS} controls={controls} searchPlaceholder="Search wagon types" />
<Paper withBorder radius="md">
<ScrollArea>
@@ -789,7 +834,7 @@ export function WagonTypesCrudPage() {
Showing {sorted.length === 0 ? 0 : (page - 1) * pageSize + 1}-{Math.min(page * pageSize, sorted.length)} of{' '}
{sorted.length}
</Text>
<Pagination total={pageCount} value={page} onChange={setPage} size="sm" />
<Pagination total={pageCount} value={page} onChange={controls.setPage} size="sm" />
</Group>
</Stack>
@@ -906,6 +951,7 @@ export function WagonsCrudPage() {
create={useMutation(api.wagons.create.mutationOptions())}
update={useMutation(api.wagons.update.mutationOptions())}
remove={useMutation(api.wagons.remove.mutationOptions())}
statusOptions={WAGON_STATUS_OPTIONS}
searchText={(wagon) => [
wagon.wagonNumber,
wagon.wagonTypeId,
@@ -959,14 +1005,7 @@ export function WagonsCrudPage() {
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'IMPORT_READY', label: 'Import ready' },
{ value: 'EXPORT_READY', label: 'Export ready' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'DETAINED', label: 'Detained' },
],
options: WAGON_STATUS_OPTIONS,
},
{ key: 'notes', label: 'Notes' },
]}
@@ -999,6 +1038,7 @@ export function ContainersCrudPage() {
create={useMutation(api.containers.create.mutationOptions())}
update={useMutation(api.containers.update.mutationOptions())}
remove={useMutation(api.containers.remove.mutationOptions())}
statusOptions={CONTAINER_STATUS_OPTIONS}
searchText={(container) => [container.containerNumber, container.containerTypeId, container.wagonId, container.status].join(' ')}
columns={[
{ key: 'containerNumber', label: 'Number' },
@@ -1058,6 +1098,7 @@ export function CargoesCrudPage() {
create={useMutation(api.cargoes.create.mutationOptions())}
update={useMutation(api.cargoes.update.mutationOptions())}
remove={useMutation(api.cargoes.remove.mutationOptions())}
statusOptions={CARGO_STATUS_OPTIONS}
searchText={(cargo) => [cargo.cargoReference, cargo.description, cargo.containerId, cargo.status].join(' ')}
columns={[
{ key: 'cargoReference', label: 'Reference' },
@@ -1122,6 +1163,7 @@ export function LocomotivesCrudPage() {
removeActionLabel="Decommission"
removeConfirmMessage="Decommission this locomotive?"
removeSuccessMessage="Locomotive decommissioned"
statusOptions={LOCOMOTIVE_STATUS_OPTIONS}
searchText={(locomotive) =>
[
locomotive.code,
@@ -1166,12 +1208,7 @@ export function LocomotivesCrudPage() {
label: 'Status',
type: 'select',
required: true,
options: [
{ value: 'AVAILABLE', label: 'Available' },
{ value: 'MAINTENANCE', label: 'Maintenance' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'OUT_OF_SERVICE', label: 'Out of service' },
],
options: LOCOMOTIVE_STATUS_OPTIONS,
},
{ key: 'maxPullWeightTons', label: 'Max pulling weight (tons)', type: 'number', required: true },
{ key: 'maxTrainLengthMeters', label: 'Max train length (meters)', type: 'number', required: true },

View File

@@ -19,11 +19,16 @@ import {
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/auth/http";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -45,6 +50,8 @@ interface FuelPurchase {
}
const FUEL_FILTER_DEFS: FilterDef[] = [{ key: "purchaseDate", label: "Purchased", type: "date" }];
export default function FuelPurchasePage() {
const { toast } = useToast();
const qc = useQueryClient();
@@ -123,10 +130,18 @@ export default function FuelPurchasePage() {
const totalCost = formData.liters * formData.costPerLiter;
// Aggregate stats (guarded against divide-by-zero when there are no purchases)
const controls = useListControls(purchasesData as FuelPurchase[], {
searchKeys: ["fuelStation", "paymentMethod"],
dateKey: "purchaseDate",
});
const controls = useFilters(FUEL_FILTER_DEFS, { pageSize: 10 });
const filteredPurchases = applyClientFilters(
purchasesData as FuelPurchase[],
FUEL_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ["fuelStation", "paymentMethod"] },
);
const pagedPurchases = filteredPurchases.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
const totalLiters = (purchasesData as FuelPurchase[]).reduce(
(sum, p) => sum + Number(p.liters),
@@ -195,17 +210,11 @@ export default function FuelPurchasePage() {
{/* Purchases Table */}
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={FUEL_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search station or payment method…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Purchased"
hasFilters={controls.hasFilters}
onReset={controls.reset}
viewId="fleet-fuel-purchases"
/>
<Table striped highlightOnHover>
<Table.Thead>
@@ -237,7 +246,7 @@ export default function FuelPurchasePage() {
</Table.Td>
</Table.Tr>
) : null}
{controls.pagedRows.map((purchase) => (
{pagedPurchases.map((purchase) => (
<Table.Tr key={purchase.id}>
<Table.Td>{(purchase as any).vehicle?.registrationNumber || (purchase as any).vehicle?.plateNumber || purchase.vehicleId}</Table.Td>
<Table.Td>{new Date(purchase.purchaseDate).toLocaleDateString()}</Table.Td>
@@ -253,11 +262,8 @@ export default function FuelPurchasePage() {
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="purchases"
onPaginationChange={controls.setPagination}
{...toRuleEngineFooterProps(controls, filteredPurchases.length)}
/>
</Card>

View File

@@ -20,11 +20,16 @@ import {
} from "@mantine/core";
import { Plus } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useToast } from "@/hooks/use-toast";
import {
incidentsService,
@@ -89,6 +94,8 @@ const initialForm = {
reportedBy: "",
};
const INCIDENT_FILTER_DEFS: FilterDef[] = [{ key: "occurredAt", label: "Occurred", type: "date" }];
export default function IncidentsPage() {
const { toast } = useToast();
const qc = useQueryClient();
@@ -166,10 +173,18 @@ export default function IncidentsPage() {
})) || [];
const incidents = incidentsData as Incident[];
const controls = useListControls(incidents, {
searchKeys: ["type", "severity", "status"],
dateKey: "occurredAt",
});
const controls = useFilters(INCIDENT_FILTER_DEFS, { pageSize: 10 });
const filteredIncidents = applyClientFilters(
incidents,
INCIDENT_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ["type", "severity", "status"] },
);
const pagedIncidents = filteredIncidents.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
const totalCount = incidents.length;
const openCount = incidents.filter((i) => OPEN_STATUSES.includes(i.status)).length;
const underReviewCount = incidents.filter((i) => i.status === "UNDER_REVIEW").length;
@@ -246,17 +261,11 @@ export default function IncidentsPage() {
{/* Incidents Table */}
<Card withBorder>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={INCIDENT_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search type, severity, status…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Occurred"
hasFilters={controls.hasFilters}
onReset={controls.reset}
viewId="fleet-incidents"
/>
<Table striped highlightOnHover>
<Table.Thead>
@@ -288,7 +297,7 @@ export default function IncidentsPage() {
</Table.Td>
</Table.Tr>
) : null}
{controls.pagedRows.map((incident) => (
{pagedIncidents.map((incident) => (
<Table.Tr key={incident.id}>
<Table.Td>{new Date(incident.occurredAt).toLocaleDateString()}</Table.Td>
<Table.Td>
@@ -316,11 +325,8 @@ export default function IncidentsPage() {
</Table.Tbody>
</Table>
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="incidents"
onPaginationChange={controls.setPagination}
{...toRuleEngineFooterProps(controls, filteredIncidents.length)}
/>
</Card>

View File

@@ -8,16 +8,18 @@ import {
Grid,
Group,
Loader,
Menu,
SimpleGrid,
Stack,
Table,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Building2, Download, FileText } from "lucide-react";
import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { EimsFilingCard } from "@/components/invoices/EimsFilingCard";
import { useToast } from "@/hooks/use-toast";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
@@ -167,6 +169,7 @@ export default function InvoiceDetailPage() {
const canExport = hasPermission(user, FREIGHT_PERMS.invoices.export);
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const [downloading, setDownloading] = useState(false);
const { data: invoice, isLoading } = useQuery(
@@ -176,12 +179,27 @@ export default function InvoiceDetailPage() {
}),
);
const downloadDocument = async () => {
const downloadDocument = async (format?: "a4" | "thermal") => {
if (!id) return;
setDownloading(true);
try {
const { data } = await invoicesService.downloadDocument(id);
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
const { data } = await invoicesService.downloadDocument(id, format);
const suffix = format === "thermal" ? "-thermal" : "";
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}${suffix}.pdf`);
} catch (error) {
// Thermal rendering deliberately fails loudly rather than silently returning an A4-shaped,
// QR-less document (see PdfRenderService's `noFallback`) — surface that here rather than
// let it become a silent unhandled rejection with just a spinner stopping.
toast({
title: format === "thermal" ? "Could not generate the thermal invoice" : "Could not download the invoice",
description:
format === "thermal"
? "Thermal rendering requires Chromium on the server. The A4 PDF is still available."
: error instanceof Error
? error.message
: undefined,
variant: "destructive",
});
} finally {
setDownloading(false);
}
@@ -224,17 +242,34 @@ export default function InvoiceDetailPage() {
subtitle={humanize(invoice.source)}
meta={<InvoiceStatusBadge status={invoice.status} />}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Download invoice"
disabled={!canExport}
loading={downloading}
onClick={() => void downloadDocument()}
>
<Download size={16} />
</ActionIcon>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Download invoice"
disabled={!canExport}
loading={downloading}
>
<Download size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Download size={14} />}
onClick={() => void downloadDocument("a4")}
>
Download PDF (A4)
</Menu.Item>
<Menu.Item
leftSection={<Printer size={14} />}
onClick={() => void downloadDocument("thermal")}
>
Download thermal invoice (80mm)
</Menu.Item>
</Menu.Dropdown>
</Menu>
}
/>

View File

@@ -1,7 +1,6 @@
import { Badge, Card, Group, Text } from '@mantine/core';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
import { PageContainer, PageHeader } from '@/components/page';
import { useQuery } from '@tanstack/react-query';
@@ -63,16 +62,27 @@ const columns: ColumnDef<Loading>[] = [
},
];
const LOADING_FILTER_DEFS: FilterDef[] = [{ key: 'loadedAt', label: 'Loaded', type: 'date' }];
/** Record of every inventory item loaded onto a wagon. */
export default function LoadedInventoryPage() {
const { data, isLoading } = useQuery(
api.warehouses.loadings.queryOptions({ input: {} }),
);
const loadings = data ?? [];
const controls = useListControls(loadings, {
searchKeys: ['wagonNumber'],
dateKey: 'loadedAt',
});
const controls = useFilters(LOADING_FILTER_DEFS, { pageSize: 10 });
// Endpoint takes no params at all — everything filters client-side.
const filteredLoadings = applyClientFilters(
loadings,
LOADING_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ['wagonNumber'] },
);
const pagedLoadings = filteredLoadings.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
return (
<PageContainer>
@@ -90,24 +100,18 @@ export default function LoadedInventoryPage() {
/>
) : (
<>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={LOADING_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search by wagon…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Loaded"
hasFilters={controls.hasFilters}
onReset={controls.reset}
viewId="loaded-inventory"
/>
<DataTable
columns={columns}
data={controls.pagedRows}
data={pagedLoadings}
status={isLoading ? 'loading' : 'success'}
containerClassName="border-0 shadow-none"
{...controls.tableProps}
{...controls.tableProps(filteredLoadings.length)}
/>
</>
)}

View File

@@ -11,11 +11,16 @@ import {
} from "@mantine/core";
import { PageContainer, PageHeader } from "@/components/page";
import ListControls from "@/components/common/ListControls";
// Generic list footer — already shared by the fleet and train-scheduling lists
// despite the ruleEngine path; reused here rather than adding a second one.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useListControls } from "@/hooks/useListControls";
import {
applyClientFilters,
FilterBar,
toRuleEngineFooterProps,
useFilters,
type FilterDef,
} from "@/components/filters";
import { useTrucksOnSite } from "@/hooks/useWarehouses";
import type { TruckOnSite } from "@/types/warehouse";
import { formatDateTime } from "@/lib/format";
@@ -142,10 +147,14 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
);
}
const TRUCK_FILTER_DEFS: FilterDef[] = [{ key: "arrivedAt", label: "Arrived", type: "date" }];
export default function TrucksOnSitePage() {
const { data: trucks = [], isLoading } = useTrucksOnSite();
// The dashboard's "Trucks on-site" card counts only arrived trucks, so it
// deep-links here with ?scope=ON_SITE to land on the matching tab.
// deep-links here with ?scope=ON_SITE to land on the matching tab. Read
// once at mount, same as before — scope/source stay page-level tab state
// (mutually exclusive, dashboard-linked), not filter pills.
const [searchParams] = useSearchParams();
const scopeParam = searchParams.get("scope");
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">(
@@ -153,7 +162,7 @@ export default function TrucksOnSitePage() {
);
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
// Scope/source are page filters and run first; the shared control then does
// Scope/source are page filters and run first; the filter bar then does
// search + arrival-date range + pagination over what they leave.
const scoped = useMemo(
() =>
@@ -163,10 +172,14 @@ export default function TrucksOnSitePage() {
[trucks, scope, source],
);
const controls = useListControls(scoped, {
const controls = useFilters(TRUCK_FILTER_DEFS, { pageSize: 10 });
const filteredTrucks = applyClientFilters(scoped, TRUCK_FILTER_DEFS, controls.values, controls.searchText, {
searchKeys: ["plateNumber", "driverName", "bookingReference", "customerName", "containers"],
dateKey: "arrivedAt",
});
const pagedTrucks = filteredTrucks.slice(
(controls.page - 1) * controls.pageSize,
controls.page * controls.pageSize,
);
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
const inboundCount = trucks.length - onSiteCount;
@@ -185,7 +198,10 @@ export default function TrucksOnSitePage() {
<SegmentedControl
size="xs"
value={scope}
onChange={(v) => setScope(v as typeof scope)}
onChange={(v) => {
setScope(v as typeof scope);
controls.setPage(1);
}}
data={[
{ label: `All (${trucks.length})`, value: "ALL" },
{ label: `On site (${onSiteCount})`, value: "ON_SITE" },
@@ -195,7 +211,10 @@ export default function TrucksOnSitePage() {
<SegmentedControl
size="xs"
value={source}
onChange={(v) => setSource(v as typeof source)}
onChange={(v) => {
setSource(v as typeof source);
controls.setPage(1);
}}
data={[
{ label: "All", value: "ALL" },
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
@@ -205,30 +224,21 @@ export default function TrucksOnSitePage() {
</Group>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={TRUCK_FILTER_DEFS}
controls={controls}
searchPlaceholder="Plate, driver, booking, container…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Arrived"
hasFilters={controls.hasFilters}
onReset={controls.reset}
viewId="trucks-on-site"
/>
{isLoading ? (
<Text size="sm">Loading</Text>
) : (
<>
<Rows rows={controls.pagedRows} />
<Rows rows={pagedTrucks} />
<RuleEngineListFooter
pagination={controls.pagination}
pageCount={controls.pageCount}
totalCount={controls.totalCount}
itemLabel="trucks"
onPaginationChange={controls.setPagination}
{...toRuleEngineFooterProps(controls, filteredTrucks.length)}
/>
</>
)}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
ActionIcon,
Badge,
@@ -18,8 +18,7 @@ import {
import { Ban, CreditCard, DoorOpen, Download, ExternalLink, Eye, Receipt } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { DataTable, type ColumnDef } from '@edr/ui-common';
import ListControls from '@/components/common/ListControls';
import { useListControls } from '@/hooks/useListControls';
import { applyClientFilters, FilterBar, useFilters, type FilterDef } from '@/components/filters';
import { PageContainer, PageHeader } from '@/components/page';
import { AccrualDashboard } from '@/components/warehouses';
@@ -54,9 +53,21 @@ const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
const fmt = (n: number, c: string) => formatMoney(n, c, 2);
const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—');
const INVOICE_FILTER_DEFS: FilterDef[] = [
{
key: 'status',
label: 'Status',
type: 'enum',
multiple: false,
options: WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') })),
},
{ key: 'issuedAt', label: 'Issued', type: 'date' },
];
export default function WarehouseInvoicesPage() {
const [status, setStatus] = useState<WarehouseInvoiceStatus | null>(null);
const [detailId, setDetailId] = useState<string | null>(null);
const controls = useFilters(INVOICE_FILTER_DEFS, { pageSize: 10 });
const status = (controls.values.status?.v[0] as WarehouseInvoiceStatus | undefined) ?? null;
const { data, isLoading } = useQuery(
api.warehouses.invoices.queryOptions({
@@ -65,10 +76,20 @@ export default function WarehouseInvoicesPage() {
);
const invoices = data ?? [];
const controls = useListControls(invoices, {
searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'],
dateKey: 'issuedAt',
});
// Endpoint only filters by `status`; search + issued-date range are
// applied client-side (the client bridge — see components/filters/clientFilter.ts).
// Flip to server mode by deleting this call once the endpoint takes more params.
const filteredInvoices = applyClientFilters(
invoices,
INVOICE_FILTER_DEFS,
controls.values,
controls.searchText,
{ searchKeys: ['invoiceNumber', 'bookingReference', 'customerName', 'containerNumber'] },
);
const pagedInvoices = useMemo(
() => filteredInvoices.slice((controls.page - 1) * controls.pageSize, controls.page * controls.pageSize),
[filteredInvoices, controls.page, controls.pageSize],
);
const invoiceColumns: ColumnDef<WarehouseFeeInvoice>[] = [
{
@@ -135,36 +156,20 @@ export default function WarehouseInvoicesPage() {
</Stack>
<Card>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
<FilterBar
defs={INVOICE_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search invoice no / booking / customer"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Issued"
hasFilters={controls.hasFilters}
onReset={controls.reset}
>
<Select
label="Status"
placeholder="All statuses"
data={WAREHOUSE_INVOICE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, ' ') }))}
value={status}
onChange={(v) => setStatus((v as WarehouseInvoiceStatus) ?? null)}
clearable
w={200}
/>
</ListControls>
viewId="warehouse-invoices"
/>
<DataTable
columns={invoiceColumns}
data={controls.pagedRows}
data={pagedInvoices}
status={isLoading ? 'loading' : 'success'}
emptyMessage="No invoices found."
containerClassName="border-0 shadow-none"
{...controls.tableProps}
{...controls.tableProps(filteredInvoices.length)}
/>
</Card>