mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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 (Z–A)" },
|
||||
] 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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user