mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Badge,
|
|
Box,
|
|
Card,
|
|
Group,
|
|
SegmentedControl,
|
|
Stack,
|
|
Text,
|
|
TextInput,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import { useDebouncedValue } from "@mantine/hooks";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Building2,
|
|
CheckCircle2,
|
|
Clock,
|
|
Hourglass,
|
|
Mail,
|
|
Phone,
|
|
RefreshCw,
|
|
Search,
|
|
ShieldOff,
|
|
Users,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import {
|
|
CompanyStatusBadge,
|
|
CompanyTypeBadge,
|
|
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" | "onboarding" | "active";
|
|
|
|
const VIEW_FILTERS: Record<
|
|
CustomerView,
|
|
{ status?: CompanyStatus; onboardingCompleted?: boolean }
|
|
> = {
|
|
all: {},
|
|
pending: { status: "pending", onboardingCompleted: true },
|
|
onboarding: { onboardingCompleted: false },
|
|
active: { status: "active" },
|
|
};
|
|
|
|
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 filter = useMemo(
|
|
() => ({
|
|
page: pagination.pageIndex + 1,
|
|
pageSize: pagination.pageSize,
|
|
search: debouncedQuery,
|
|
...VIEW_FILTERS[view],
|
|
}),
|
|
[pagination.pageIndex, pagination.pageSize, debouncedQuery, 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 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>
|
|
{c.name}
|
|
</Text>
|
|
<CompanyTypeBadge type={c.type} />
|
|
</Group>
|
|
<Text size="xs" c="dimmed">
|
|
TIN {c.tin}
|
|
{c.country ? ` · ${c.country}` : ""}
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "profiles",
|
|
header: "Profiles",
|
|
cell: ({ row }) => <ProfileChips profiles={row.original.companyProfiles} />,
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
cell: ({ row }) => {
|
|
// A draft's profiles are all `pending` by construction, so the
|
|
// "N pending" review hint 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>
|
|
);
|
|
}
|
|
const pending = (row.original.companyProfiles ?? []).filter(
|
|
(p) => p.status === "pending",
|
|
).length;
|
|
return (
|
|
<Group gap={6} wrap="nowrap">
|
|
<CompanyStatusBadge status={row.original.status} />
|
|
{pending > 0 ? (
|
|
<Tooltip
|
|
label={`${pending} profile${pending > 1 ? "s" : ""} awaiting approval`}
|
|
>
|
|
<Badge color="yellow" variant="light" size="sm" radius="sm">
|
|
{pending} pending
|
|
</Badge>
|
|
</Tooltip>
|
|
) : null}
|
|
</Group>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
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
|
|
>
|
|
<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: "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: "Onboarding", value: "onboarding" },
|
|
{ label: "Active", value: "active" },
|
|
]}
|
|
/>
|
|
<Text size="sm" c="dimmed">
|
|
{total} record{total !== 1 ? "s" : ""}
|
|
</Text>
|
|
</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>
|
|
);
|
|
}
|