mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
import {
|
||
ActionIcon,
|
||
Badge,
|
||
Box,
|
||
Card,
|
||
Group,
|
||
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 } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
|
||
import {
|
||
CompanyNationalityBadge,
|
||
CompanyStatusBadge,
|
||
ManualRegistrationBadge,
|
||
ProfileChips,
|
||
formatDate,
|
||
humanize,
|
||
} from "@/components/customers";
|
||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||
import { api } from "@/services/api";
|
||
import type { Company, CompanyListFilter } from "@/types/customer";
|
||
import { isOnboardingDraft } from "@/types/customer";
|
||
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
|
||
import {
|
||
FilterBar,
|
||
dateRangeParams,
|
||
isoToLocalDateStr,
|
||
useFilters,
|
||
type FilterDef,
|
||
} from "@/components/filters";
|
||
import { ExportButton } from "@/components/export/ExportButton";
|
||
|
||
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;
|
||
|
||
/**
|
||
* Every state a customer can be in, as one single-select list.
|
||
*
|
||
* Three of these are not `companies.status` values at all, which is why each
|
||
* option maps its own params:
|
||
* - **Pending approval** is 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.
|
||
* - **Onboarding** is that draft: still in the portal wizard, never submitted.
|
||
* - **Pending changes** is an already-approved (`active`) customer who edited
|
||
* their profile. `status` can never match them, so without this option their
|
||
* resubmission is only visible by opening their detail page.
|
||
*/
|
||
const STATUS_OPTIONS: {
|
||
value: string;
|
||
label: string;
|
||
params: Record<string, string>;
|
||
}[] = [
|
||
{ value: "pending", label: "Pending approval", params: { status: "pending", onboardingCompleted: "true" } },
|
||
{ value: "pendingChanges", label: "Pending changes", params: { hasPendingChangeRequest: "true" } },
|
||
{ value: "onboarding", label: "Onboarding", params: { onboardingCompleted: "false" } },
|
||
{ value: "active", label: "Active", params: { status: "active" } },
|
||
{ value: "suspended", label: "Suspended", params: { status: "suspended" } },
|
||
{ value: "blacklisted", label: "Blacklisted", params: { status: "blacklisted" } },
|
||
];
|
||
|
||
/**
|
||
* Filter pills. The review queues that used to sit beside them as segmented
|
||
* tabs are folded into the Status pill above — three of the five were never a
|
||
* plain `status` value, so as a separate tab strip they could contradict the
|
||
* status filter next to them. One list, mutually exclusive, no contradiction.
|
||
*/
|
||
const CUSTOMER_FILTER_DEFS: FilterDef[] = [
|
||
{
|
||
key: "status",
|
||
label: "Status",
|
||
type: "enum",
|
||
multiple: false,
|
||
options: STATUS_OPTIONS.map(({ value, label }) => ({ value, label })),
|
||
toParams: (v) =>
|
||
STATUS_OPTIONS.find((o) => o.value === v.v[0])?.params ?? {},
|
||
},
|
||
{
|
||
key: "type",
|
||
label: "Type",
|
||
type: "enum",
|
||
multiple: false,
|
||
options: (
|
||
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
|
||
).map((value) => ({ value, label: humanize(value) })),
|
||
},
|
||
{
|
||
key: "kind",
|
||
label: "Sector",
|
||
type: "enum",
|
||
multiple: false,
|
||
options: [
|
||
{ value: "commercial", label: "Commercial" },
|
||
{ value: "government", label: "Government" },
|
||
],
|
||
},
|
||
{
|
||
key: "nationality",
|
||
label: "Nationality",
|
||
type: "enum",
|
||
multiple: false,
|
||
options: [
|
||
{ value: "ethiopian", label: "Ethiopian" },
|
||
{ value: "foreign", label: "Foreign" },
|
||
],
|
||
},
|
||
{
|
||
key: "created",
|
||
label: "Registered",
|
||
type: "date",
|
||
secondary: true,
|
||
operators: ["between", "before", "after"],
|
||
toParams: dateRangeParams("createdFrom", "createdTo"),
|
||
},
|
||
];
|
||
|
||
export default function CustomersPage() {
|
||
const navigate = useNavigate();
|
||
const controls = useFilters(CUSTOMER_FILTER_DEFS, {
|
||
defaultSort: "review:DESC",
|
||
pageSize: 10,
|
||
});
|
||
|
||
// `controls.params` is the whole query: page/pageSize/search, the split
|
||
// sortBy/sortOrder, and every pill's mapped params.
|
||
const filter = controls.params as unknown as CompanyListFilter;
|
||
|
||
/**
|
||
* The export's `daterange` filters are coerced from calendar days while the
|
||
* list takes ISO instants — hand the dialog the local day each bound falls on
|
||
* so the file covers the same range the screen shows.
|
||
*/
|
||
const exportParams = useMemo(() => {
|
||
const out: Record<string, unknown> = { ...controls.params };
|
||
for (const key of ["createdFrom", "createdTo"]) {
|
||
if (typeof out[key] === "string") out[key] = isoToLocalDateStr(out[key] as string);
|
||
}
|
||
return out;
|
||
}, [controls.params]);
|
||
|
||
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={CUSTOMER_FILTER_DEFS}
|
||
controls={controls}
|
||
searchPlaceholder="Search by company, TIN, email or profile reference…"
|
||
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||
viewId="customers"
|
||
>
|
||
<ExportButton datasetKey="customers" params={exportParams} />
|
||
</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.activeCount > 0
|
||
? "No companies match these filters."
|
||
: "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>
|
||
);
|
||
}
|