mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
fix(useFilters): pageSize was a hardcoded constant, never read from the URL, with no setter — the DataTable/RuleEngineListFooter page-size dropdown silently did nothing on every page using the filter bar, including the already-shipped ContractRequestsPage pilot. Added a `size` URL param (mirrors `page`), `setPageSize`, and wired `tableProps().onPaginationChange` to route page-size vs page-index changes to the right setter instead of only ever calling setPage(). feat(clientFilter): applyClientFilters — the Family-B bridge the plan called for. Generalizes ListControls'/useListControls' one hardcoded search box + one date range to every FilterDef, matched against `row[def.key]`. Reuses matchesDayRange/toDayString from hooks/useListControls.ts (imported, not duplicated) so the inclusive- range/timezone-safe semantics stay defined in exactly one place. Lets a page ship the full pill-bar UI immediately and flip to server-side filtering later by deleting one function call — no endpoint changes required up front. Migrated to the filter bar (mechanical, pattern established by ContractRequestsPage): - WarehouseInvoicesPage, LoadedInventoryPage, TrucksOnSitePage — Family B (ListControls/useListControls → FilterBar + applyClientFilters) - CustomersPage — Family A (useState bag → useFilters), no filter pills needed here (search + sort only), the SegmentedControl "view" stays page-level tab state (like ContractStatusTabs), not a filter pill — it now resets the page via controls.setPage(1) on change, the same hazard useFilters already guards its own filters against
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
import {
|
||
ActionIcon,
|
||
Badge,
|
||
Box,
|
||
Card,
|
||
Group,
|
||
SegmentedControl,
|
||
Stack,
|
||
Text,
|
||
Tooltip,
|
||
} from "@mantine/core";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import {
|
||
Building2,
|
||
CheckCircle2,
|
||
Clock,
|
||
FilePen,
|
||
Hourglass,
|
||
Mail,
|
||
Phone,
|
||
RefreshCw,
|
||
ShieldOff,
|
||
Users,
|
||
} from "lucide-react";
|
||
import { useMemo, useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
|
||
import {
|
||
CompanyNationalityBadge,
|
||
CompanyStatusBadge,
|
||
ProfileChips,
|
||
formatDate,
|
||
} from "@/components/customers";
|
||
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, 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-
|
||
* review, so it excludes drafts — a company row exists from the onboarding
|
||
* wizard's first click and would otherwise pad the review queue. Those drafts
|
||
* get their own view instead of disappearing, so staff can still chase them.
|
||
*/
|
||
type CustomerView =
|
||
| "all"
|
||
| "pending"
|
||
| "pendingChanges"
|
||
| "onboarding"
|
||
| "active";
|
||
|
||
/**
|
||
* "Pending changes" is deliberately not folded into "Pending approval". A
|
||
* customer who edits their profile after being approved stays `status = active`,
|
||
* so the pending filter can never match them — their resubmission would only
|
||
* ever be visible by opening their detail page. This view is that queue.
|
||
*/
|
||
const VIEW_FILTERS: Record<
|
||
CustomerView,
|
||
{
|
||
status?: CompanyStatus;
|
||
onboardingCompleted?: boolean;
|
||
hasPendingChangeRequest?: boolean;
|
||
}
|
||
> = {
|
||
all: {},
|
||
pending: { status: "pending", onboardingCompleted: true },
|
||
pendingChanges: { hasPendingChangeRequest: true },
|
||
onboarding: { onboardingCompleted: false },
|
||
active: { status: "active" },
|
||
};
|
||
|
||
const SORT_OPTIONS = [
|
||
// Queue ordering: awaiting first approval → pending profile changes → the
|
||
// rest, newest first within each group. The default, so whatever marketing
|
||
// must act on is always on top of the list.
|
||
{ value: "review:DESC", label: "Needs review first" },
|
||
{ value: "createdAt:DESC", label: "Newest first" },
|
||
{ value: "createdAt:ASC", label: "Oldest first" },
|
||
{ value: "name:ASC", label: "Name (A–Z)" },
|
||
{ 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 [view, setView] = useState<CustomerView>("all");
|
||
const controls = useFilters(NO_FILTER_DEFS, { defaultSort: "review:DESC", pageSize: 10 });
|
||
|
||
const filter = useMemo(() => {
|
||
const [sortBy, sortOrder] = controls.sort.split(":") as [
|
||
"review" | "name" | "createdAt" | "updatedAt",
|
||
"ASC" | "DESC",
|
||
];
|
||
return {
|
||
page: controls.page,
|
||
pageSize: controls.pageSize,
|
||
search: String(controls.params.search ?? ""),
|
||
sortBy,
|
||
sortOrder,
|
||
...VIEW_FILTERS[view],
|
||
};
|
||
}, [controls.page, controls.pageSize, controls.params.search, controls.sort, view]);
|
||
|
||
const { data: stats } = useQuery(
|
||
api.customers.stats.queryOptions({ input: {} }),
|
||
);
|
||
|
||
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||
api.customers.list.queryOptions({ input: { filter } }),
|
||
);
|
||
|
||
const rows = data?.items ?? [];
|
||
const total = data?.total ?? 0;
|
||
|
||
const columns: ColumnDef<Company>[] = useMemo(
|
||
() => [
|
||
{
|
||
id: "company",
|
||
header: "Company",
|
||
cell: ({ row }) => {
|
||
const c = row.original;
|
||
return (
|
||
<Group gap="sm" wrap="nowrap">
|
||
<Box
|
||
className="flex size-9 shrink-0 items-center justify-center rounded-lg"
|
||
style={{
|
||
background: "var(--mantine-color-edr-green-1)",
|
||
color: "var(--mantine-color-edr-green-7)",
|
||
}}
|
||
>
|
||
<Building2 size={18} strokeWidth={1.9} />
|
||
</Box>
|
||
<div style={{ minWidth: 0 }}>
|
||
<Group gap={8} wrap="nowrap">
|
||
<Text fw={600} c="edr-text" truncate maw={200}>
|
||
{c.name}
|
||
</Text>
|
||
<CompanyNationalityBadge nationality={c.nationality} />
|
||
</Group>
|
||
<Text size="xs" c="dimmed">
|
||
TIN {c.tin}
|
||
{c.country ? ` · ${c.country}` : ""}
|
||
</Text>
|
||
<Box mt={4}>
|
||
<CompanyStatusBadge status={c.status} />
|
||
</Box>
|
||
</div>
|
||
</Group>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: "profiles",
|
||
header: "Profiles",
|
||
cell: ({ row }) => {
|
||
// A draft's profiles are all `pending` by construction — the
|
||
// status-colored chips would be a lie until they submit.
|
||
if (isOnboardingDraft(row.original)) {
|
||
return (
|
||
<Tooltip label="Customer is still filling in the onboarding wizard">
|
||
<Badge color="gray" variant="light" size="sm" radius="sm">
|
||
Onboarding
|
||
</Badge>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
return <ProfileChips profiles={row.original.companyProfiles} />;
|
||
},
|
||
},
|
||
{
|
||
id: "contact",
|
||
header: "Contact",
|
||
cell: ({ row }) => {
|
||
const c = row.original;
|
||
return (
|
||
<Stack gap={2}>
|
||
{c.contactPersonName ? (
|
||
<Text size="sm" c="edr-text">
|
||
{c.contactPersonName}
|
||
</Text>
|
||
) : null}
|
||
{c.phone ? (
|
||
<Text
|
||
size="xs"
|
||
c="dimmed"
|
||
className="inline-flex items-center gap-1"
|
||
>
|
||
<Phone size={12} /> {c.phone}
|
||
</Text>
|
||
) : null}
|
||
{c.email ? (
|
||
<Text
|
||
size="xs"
|
||
c="dimmed"
|
||
className="inline-flex items-center gap-1"
|
||
truncate
|
||
maw={200}
|
||
>
|
||
<Mail size={12} /> {c.email}
|
||
</Text>
|
||
) : null}
|
||
</Stack>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: "created",
|
||
header: "Registered",
|
||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||
cell: ({ row }) => (
|
||
<Text size="sm" c="dimmed">
|
||
{formatDate(row.original.createdAt)}
|
||
</Text>
|
||
),
|
||
},
|
||
],
|
||
[],
|
||
);
|
||
|
||
return (
|
||
<PageContainer>
|
||
<PageHeader
|
||
title="Customers"
|
||
subtitle="Companies registered for freight services, with their role profiles."
|
||
action={
|
||
<ActionIcon
|
||
variant="default"
|
||
size="lg"
|
||
radius="md"
|
||
aria-label="Refresh"
|
||
loading={isFetching}
|
||
onClick={() => void refetch()}
|
||
>
|
||
<RefreshCw size={16} />
|
||
</ActionIcon>
|
||
}
|
||
/>
|
||
|
||
<KpiStrip
|
||
items={[
|
||
{
|
||
label: "Companies",
|
||
value: stats?.total ?? "—",
|
||
icon: Users,
|
||
color: "edr-green",
|
||
},
|
||
{
|
||
label: "Active",
|
||
value: stats?.active ?? "—",
|
||
icon: CheckCircle2,
|
||
color: "edr-green",
|
||
},
|
||
{
|
||
label: "Pending",
|
||
value: stats?.pending ?? "—",
|
||
icon: Clock,
|
||
color: "yellow",
|
||
},
|
||
{
|
||
label: "Pending changes",
|
||
value: stats?.pendingChanges ?? "—",
|
||
icon: FilePen,
|
||
color: "yellow",
|
||
},
|
||
{
|
||
label: "Onboarding",
|
||
value: stats?.onboarding ?? "—",
|
||
icon: Hourglass,
|
||
color: "gray",
|
||
},
|
||
{
|
||
label: "Blacklisted",
|
||
value: stats?.blacklisted ?? "—",
|
||
icon: ShieldOff,
|
||
color: "red",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<Card p={0}>
|
||
<Stack gap={0}>
|
||
<Box px="md" pt="md" pb="sm" w="100%">
|
||
<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);
|
||
controls.setPage(1);
|
||
}}
|
||
data={[
|
||
{ label: "All", value: "all" },
|
||
{ label: "Pending approval", value: "pending" },
|
||
{ label: "Pending changes", value: "pendingChanges" },
|
||
{ label: "Onboarding", value: "onboarding" },
|
||
{ label: "Active", value: "active" },
|
||
]}
|
||
/>
|
||
</FilterBar>
|
||
</Box>
|
||
|
||
<Box style={{ overflowX: "auto" }} w="100%">
|
||
<Box miw={980}>
|
||
<DataTable
|
||
columns={columns}
|
||
data={rows}
|
||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||
onRowClick={(row) => navigate(`/dashboard/customers/${row.id}`)}
|
||
emptyMessage={
|
||
controls.searchText
|
||
? "No companies match your search."
|
||
: "No companies yet."
|
||
}
|
||
error={
|
||
isError
|
||
? {
|
||
message: "Failed to load customers.",
|
||
onRetry: () => void refetch(),
|
||
}
|
||
: undefined
|
||
}
|
||
{...controls.tableProps(total)}
|
||
containerClassName="border-0 shadow-none bg-transparent"
|
||
footer={DataTableFooter}
|
||
/>
|
||
</Box>
|
||
</Box>
|
||
</Stack>
|
||
</Card>
|
||
</PageContainer>
|
||
);
|
||
}
|