Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx
2026-08-13 13:46:46 +00:00

395 lines
12 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,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import {
Building2,
CheckCircle2,
Clock,
FilePen,
Hourglass,
Mail,
Phone,
RefreshCw,
Search,
ShieldOff,
Users,
X,
} 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,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
/**
* 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;
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 filter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [
"review" | "name" | "createdAt" | "updatedAt",
"ASC" | "DESC",
];
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
sortBy,
sortOrder,
...VIEW_FILTERS[view],
};
}, [pagination.pageIndex, pagination.pageSize, debouncedQuery, view, sort]);
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 pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
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%">
<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"
/>
<SegmentedControl
size="sm"
radius="md"
value={view}
onChange={(v) => {
setView(v as CustomerView);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending approval", value: "pending" },
{ label: "Pending changes", value: "pendingChanges" },
{ label: "Onboarding", value: "onboarding" },
{ 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>
</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={
debouncedQuery
? "No companies match your search."
: "No companies yet."
}
error={
isError
? {
message: "Failed to load customers.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Box>
</Stack>
</Card>
</PageContainer>
);
}