mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Two kinds of customer reach approval with a registration nobody checked: a co-operative union or farm, which holds no trade licence, and a foreign investor, whose licence comes from the Investment Commission rather than the trade registry. Both were reviewed on screens that read exactly like an eTrade-verified company's, with only a small Registration field naming the difference. They now carry an orange "Manual entry" badge in the customers list and beside the company name, and their overview opens with an alert saying the name, registration and address below are the customer's own statement — pointing the reviewer at the paper that stands in for the licence (the co-operative certificate, or the investment licence) before approving. Approval itself is not blocked.
355 lines
11 KiB
TypeScript
355 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,
|
||
ManualRegistrationBadge,
|
||
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} />
|
||
<ManualRegistrationBadge
|
||
cooperative={c.cooperative}
|
||
investorLicence={c.investorLicence}
|
||
/>
|
||
</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>
|
||
);
|
||
}
|