Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx
Nathnael d94c444a51 feat(filter-bar): fix pageSize bug, add client bridge, migrate 4 more pages
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
2026-08-14 14:03:33 +00:00

350 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (AZ)" },
{ value: "name:DESC", label: "Name (ZA)" },
] as const;
/** No filter pills — search/sort/page are the only real filter dimensions;
* `view` below is a tab (mutually exclusive, navigational), not a filter. */
const NO_FILTER_DEFS: FilterDef[] = [];
export default function CustomersPage() {
const navigate = useNavigate();
const [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>
);
}