Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 | 1x 1x 7x 2x 8x 5x 7x 20x 7x 7x 3x 7x 4x 7x 1x 7x 1x | "use client";
import {keepPreviousData, useQuery} from "@tanstack/react-query";
import {CreditCard, ShieldAlert, Sparkles, UsersRound} from "lucide-react";
import {useMemo, useState} from "react";
import {CustomerTable} from "../customer-table/customer-table";
import {DashboardShell} from "@/components/dashboard/dashboard-shell/dashboard-shell";
import {Skeleton} from "@/components/ui/skeleton/skeleton";
import {uiStyles} from "@/components/ui/style-primitives";
import {fetchCustomers} from "@/lib/api/api";
import {cn, formatCurrency, formatNumber} from "@/lib/utils/utils";
import type {CustomerFilters, CustomersResponse} from "@/types/dashboard";
const pageSize = 8;
function CustomerSummary({
data,
isLoading,
}: {
data?: CustomersResponse;
isLoading: boolean;
}) {
if (isLoading) {
return (
<section aria-label="Customer summary" className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{Array.from({length: 4}).map((_, index) => (
<Skeleton className="h-28" key={index}/>
))}
</section>
);
}
const cards = [
{
caption: "Accounts in the current segment",
icon: UsersRound,
label: "Total customers",
value: formatNumber(data?.total ?? 0),
},
{
caption: "Recurring revenue in scope",
icon: CreditCard,
label: "Filtered MRR",
value: formatCurrency(data?.summary.totalMrr ?? 0, true),
},
{
caption: "Past due customers needing follow-up",
icon: ShieldAlert,
label: "At risk",
value: formatNumber(data?.summary.atRisk ?? 0),
},
{
caption: "Trials ready for conversion review",
icon: Sparkles,
label: "Trials",
value: formatNumber(data?.summary.trial ?? 0),
},
];
return (
<section aria-label="Customer summary" className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{cards.map((card) => (
<article className={cn("p-4", uiStyles.surface)} key={card.label}>
<p className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<card.icon aria-hidden="true" size={16}/>
{card.label}
</p>
<p className="mt-3 text-2xl font-semibold text-slate-950 dark:text-white">{card.value}</p>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{card.caption}</p>
</article>
))}
</section>
);
}
/** Focused customer route with segmentation, paging, and account health context. */
export function CustomersPageClient() {
const [filters, setFilters] = useState<CustomerFilters>({
page: 1,
pageSize,
plan: "all",
query: "",
status: "all",
});
const customerFilters = useMemo(
() => ({
page: filters.page ?? 1,
pageSize,
plan: filters.plan ?? "all",
query: filters.query ?? "",
status: filters.status ?? "all",
}),
[filters.page, filters.plan, filters.query, filters.status],
);
const customersQuery = useQuery({
placeholderData: keepPreviousData,
queryFn: () => fetchCustomers(customerFilters),
queryKey: ["customers", customerFilters],
});
const updateFilters = (nextFilters: Partial<CustomerFilters>) => {
setFilters((current) => ({...current, ...nextFilters}));
};
return (
<DashboardShell
activeSection="customers"
description="Segment customers, monitor risk, and review account-level health without crowding the executive overview."
eyebrow="Customers"
isRefreshing={customersQuery.isFetching}
onRefresh={() => void customersQuery.refetch()}
title="Customers"
>
<CustomerSummary data={customersQuery.data} isLoading={customersQuery.isLoading}/>
<CustomerTable
data={customersQuery.data}
filters={customerFilters}
isError={customersQuery.isError}
isFetching={customersQuery.isFetching}
isLoading={customersQuery.isLoading}
onChangeFilters={updateFilters}
onRetry={() => void customersQuery.refetch()}
/>
</DashboardShell>
);
}
|