748 lines
32 KiB
TypeScript
748 lines
32 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Users,
|
|
Search,
|
|
Eye,
|
|
Loader2,
|
|
MapPin,
|
|
Package,
|
|
X,
|
|
TrendingUp,
|
|
UserPlus,
|
|
Zap,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
History,
|
|
Filter,
|
|
Mail,
|
|
Phone,
|
|
RefreshCw,
|
|
} from 'lucide-react';
|
|
import { apiFetch } from '@/services/api/client';
|
|
import { format } from 'date-fns';
|
|
import { toast } from 'sonner';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
import { ViewModeToggle } from '@/components/ui/ViewModeToggle';
|
|
import { CustomSelect } from '@/components/ui/CustomSelect';
|
|
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
|
|
import {
|
|
StatsSparklineCard,
|
|
datesToSparkline,
|
|
weekOverWeekChange,
|
|
} from '@/components/ui/StatsSparklineCard';
|
|
|
|
interface CustomerAddress {
|
|
address_id: string;
|
|
address_type: string;
|
|
full_name: string;
|
|
phone: string;
|
|
street_address: string;
|
|
city: string;
|
|
state: string;
|
|
pincode: string;
|
|
is_default: boolean;
|
|
}
|
|
|
|
interface CustomerOrder {
|
|
order_id: string;
|
|
order_no: string;
|
|
final_amount: number;
|
|
status: string;
|
|
created_at: string;
|
|
items_count?: number;
|
|
}
|
|
|
|
interface CustomerResponse {
|
|
customer_id: string;
|
|
email: string;
|
|
first_name: string;
|
|
last_name: string;
|
|
phone: string | null;
|
|
created_at: string;
|
|
credit_balance?: number;
|
|
total_spent?: number;
|
|
total_orders?: number;
|
|
}
|
|
|
|
interface CustomerDetailResponse extends CustomerResponse {
|
|
profile_picture: string | null;
|
|
addresses: CustomerAddress[];
|
|
orders: CustomerOrder[];
|
|
}
|
|
|
|
type OrderFilter = 'all' | 'has_orders' | 'no_orders';
|
|
type PhoneFilter = 'all' | 'has_phone' | 'no_phone';
|
|
type CreditFilter = 'all' | 'has_credits' | 'no_credits';
|
|
type InsightTab = 'all' | 'loyal' | 'recent' | 'avg';
|
|
|
|
const EMPTY_FILTERS = {
|
|
orders: 'all' as OrderFilter,
|
|
phone: 'all' as PhoneFilter,
|
|
credits: 'all' as CreditFilter,
|
|
};
|
|
|
|
const WEEK_MS = 7 * 86400000;
|
|
|
|
function isNewThisWeek(date: string) {
|
|
const t = new Date(date).getTime();
|
|
return Number.isFinite(t) && Date.now() - t < WEEK_MS;
|
|
}
|
|
|
|
const PurchaseHistory = ({ customerId }: { customerId: string }) => {
|
|
const [orders, setOrders] = useState<CustomerOrder[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const fetchHistory = async () => {
|
|
try {
|
|
const data = await apiFetch<CustomerDetailResponse>(`/api/v1/admin/customers/${customerId}`);
|
|
setOrders(data.orders || []);
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchHistory();
|
|
}, [customerId]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="py-6 flex justify-center items-center">
|
|
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (orders.length === 0) {
|
|
return (
|
|
<div className="py-6 text-center text-[13px] text-muted-foreground">
|
|
<Package className="w-6 h-6 mx-auto mb-2 text-muted-foreground/40" />
|
|
No orders yet
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-4 mb-4 overflow-hidden crm-radius-section border border-border">
|
|
<div className="px-4 py-2.5 border-b border-border bg-gray-50 flex justify-between items-center">
|
|
<div className="flex items-center gap-1.5 text-[13px] font-semibold text-foreground">
|
|
<History className="w-3.5 h-3.5" />
|
|
Purchase history
|
|
</div>
|
|
<span className="inline-flex items-center px-2.5 py-1 crm-radius-badge bg-primary/10 text-primary text-[11px] font-semibold">
|
|
{orders.length} orders
|
|
</span>
|
|
</div>
|
|
<table className="crm-data-table">
|
|
<thead>
|
|
<tr className="border-b border-border bg-gray-50">
|
|
<th className="py-2.5 px-4 text-[13px] font-semibold text-gray-700">Date</th>
|
|
<th className="py-2.5 px-4 text-[13px] font-semibold text-gray-700">Order</th>
|
|
<th className="py-2.5 px-4 text-[13px] font-semibold text-gray-700 text-right">Amount</th>
|
|
<th className="py-2.5 px-4 text-[13px] font-semibold text-gray-700 text-right">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{orders.map((o) => (
|
|
<tr key={o.order_id} className="border-b border-border last:border-b-0 hover:bg-muted/10">
|
|
<td className="py-2.5 px-4 text-muted-foreground">{format(new Date(o.created_at), 'dd-MMM-yyyy')}</td>
|
|
<td className="py-2.5 px-4 font-semibold text-foreground">{o.order_no}</td>
|
|
<td className="py-2.5 px-4 text-right font-semibold text-foreground">{formatCurrency(o.final_amount)}</td>
|
|
<td className="py-2.5 px-4 text-right">
|
|
<span className="inline-flex items-center px-2.5 py-1 crm-radius-badge bg-primary/10 text-primary text-[11px] font-semibold">
|
|
{o.status}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default function CustomerIntelligencePage() {
|
|
const [customers, setCustomers] = useState<CustomerResponse[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
|
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
|
|
const [showFilterPanel, setShowFilterPanel] = useState(false);
|
|
const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS);
|
|
const [activeFilters, setActiveFilters] = useState(EMPTY_FILTERS);
|
|
const [insightTab, setInsightTab] = useState<InsightTab>('all');
|
|
const filterRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
|
|
const [detailData, setDetailData] = useState<CustomerDetailResponse | null>(null);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
|
|
const fetchCustomers = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await apiFetch<CustomerResponse[]>('/api/v1/admin/customers');
|
|
setCustomers(data);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to fetch customers';
|
|
toast.error(message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchCustomers();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!showFilterPanel) return;
|
|
const onPointerDown = (event: MouseEvent) => {
|
|
if (!filterRef.current?.contains(event.target as Node)) {
|
|
setShowFilterPanel(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
return () => document.removeEventListener('mousedown', onPointerDown);
|
|
}, [showFilterPanel]);
|
|
|
|
const handleInspectCustomer = async (id: string) => {
|
|
setSelectedCustomerId(id);
|
|
setDetailLoading(true);
|
|
setDetailData(null);
|
|
try {
|
|
const data = await apiFetch<CustomerDetailResponse>(`/api/v1/admin/customers/${id}`);
|
|
setDetailData(data);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to load customer details';
|
|
toast.error(message);
|
|
setSelectedCustomerId(null);
|
|
} finally {
|
|
setDetailLoading(false);
|
|
}
|
|
};
|
|
|
|
const toggleRow = (id: string) => {
|
|
const next = new Set(expandedRows);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
setExpandedRows(next);
|
|
};
|
|
|
|
const loyalCustomers = useMemo(
|
|
() => customers.filter((c) => (c.total_orders || 0) > 0),
|
|
[customers]
|
|
);
|
|
const recentCustomers = useMemo(
|
|
() => customers.filter((c) => isNewThisWeek(c.created_at)),
|
|
[customers]
|
|
);
|
|
const allDates = useMemo(() => customers.map((c) => c.created_at), [customers]);
|
|
const loyalDates = useMemo(() => loyalCustomers.map((c) => c.created_at), [loyalCustomers]);
|
|
const recentDates = useMemo(() => recentCustomers.map((c) => c.created_at), [recentCustomers]);
|
|
const spentDates = useMemo(
|
|
() => customers.filter((c) => (c.total_spent || 0) > 0).map((c) => c.created_at),
|
|
[customers]
|
|
);
|
|
|
|
const insights = useMemo(() => {
|
|
const total = customers.length;
|
|
const totalSpent = customers.reduce((sum, c) => sum + (c.total_spent || 0), 0);
|
|
const avgSpent = total > 0 ? totalSpent / total : 0;
|
|
const loyal = loyalCustomers.length;
|
|
return {
|
|
total,
|
|
loyal,
|
|
retentionRate: total > 0 ? Math.round((loyal / total) * 100) : 0,
|
|
newThisWeek: recentCustomers.length,
|
|
avgLtv: avgSpent,
|
|
};
|
|
}, [customers, loyalCustomers.length, recentCustomers.length]);
|
|
|
|
const filteredCustomers = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return customers.filter((c) => {
|
|
const fullName = `${c.first_name} ${c.last_name}`.toLowerCase();
|
|
const searchMatch =
|
|
!q ||
|
|
c.first_name.toLowerCase().includes(q) ||
|
|
c.last_name.toLowerCase().includes(q) ||
|
|
fullName.includes(q) ||
|
|
c.email.toLowerCase().includes(q) ||
|
|
(c.phone || '').toLowerCase().includes(q);
|
|
|
|
const orderCount = c.total_orders || 0;
|
|
const ordersMatch =
|
|
activeFilters.orders === 'all' ||
|
|
(activeFilters.orders === 'has_orders' && orderCount > 0) ||
|
|
(activeFilters.orders === 'no_orders' && orderCount === 0);
|
|
|
|
const hasPhone = Boolean(c.phone && c.phone.trim());
|
|
const phoneMatch =
|
|
activeFilters.phone === 'all' ||
|
|
(activeFilters.phone === 'has_phone' && hasPhone) ||
|
|
(activeFilters.phone === 'no_phone' && !hasPhone);
|
|
|
|
const credits = c.credit_balance || 0;
|
|
const creditsMatch =
|
|
activeFilters.credits === 'all' ||
|
|
(activeFilters.credits === 'has_credits' && credits > 0) ||
|
|
(activeFilters.credits === 'no_credits' && credits === 0);
|
|
|
|
const tabMatch =
|
|
insightTab === 'all' ||
|
|
insightTab === 'avg' ||
|
|
(insightTab === 'loyal' && orderCount > 0) ||
|
|
(insightTab === 'recent' && isNewThisWeek(c.created_at));
|
|
|
|
return searchMatch && ordersMatch && phoneMatch && creditsMatch && tabMatch;
|
|
});
|
|
}, [customers, search, activeFilters, insightTab]);
|
|
|
|
const pager = useClientPagination(filteredCustomers);
|
|
|
|
const filtersActive =
|
|
activeFilters.orders !== 'all' ||
|
|
activeFilters.phone !== 'all' ||
|
|
activeFilters.credits !== 'all';
|
|
|
|
const dataCardShell =
|
|
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0';
|
|
const labelClass = 'block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5';
|
|
const primaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50';
|
|
const secondaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50';
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 min-w-0">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold text-foreground">E-Commerce Customers</h1>
|
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
|
|
{insights.total}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={fetchCustomers}
|
|
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center shrink-0"
|
|
title="Reload"
|
|
>
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
<StatsSparklineCard
|
|
title="Total customers"
|
|
value={insights.total}
|
|
icon={Users}
|
|
tone="green"
|
|
series={datesToSparkline(allDates)}
|
|
deltaPercent={weekOverWeekChange(allDates)}
|
|
active={insightTab === 'all'}
|
|
onClick={() => setInsightTab('all')}
|
|
/>
|
|
<StatsSparklineCard
|
|
title="Loyal customers"
|
|
value={`${insights.retentionRate}%`}
|
|
icon={TrendingUp}
|
|
tone="violet"
|
|
series={datesToSparkline(loyalDates)}
|
|
deltaPercent={weekOverWeekChange(loyalDates)}
|
|
active={insightTab === 'loyal'}
|
|
onClick={() => setInsightTab('loyal')}
|
|
/>
|
|
<StatsSparklineCard
|
|
title="Recent customers"
|
|
value={insights.newThisWeek}
|
|
icon={UserPlus}
|
|
tone="orange"
|
|
series={datesToSparkline(recentDates)}
|
|
deltaPercent={weekOverWeekChange(recentDates)}
|
|
active={insightTab === 'recent'}
|
|
onClick={() => setInsightTab('recent')}
|
|
/>
|
|
<StatsSparklineCard
|
|
title="Avg sales"
|
|
value={formatCurrency(insights.avgLtv)}
|
|
icon={Zap}
|
|
tone="blue"
|
|
series={datesToSparkline(spentDates)}
|
|
deltaPercent={weekOverWeekChange(spentDates)}
|
|
active={insightTab === 'avg'}
|
|
onClick={() => setInsightTab('avg')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 min-w-0">
|
|
<div className={`lg:col-span-2 ${dataCardShell}`}>
|
|
<div className="p-4">
|
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2.5 min-w-0">
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 min-w-0">
|
|
<div className="relative" ref={filterRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraftFilters(activeFilters);
|
|
setShowFilterPanel((open) => !open);
|
|
}}
|
|
className={`inline-flex items-center gap-1.5 h-9 px-3 crm-radius-control border bg-card text-[13px] font-medium cursor-pointer transition-colors ${
|
|
filtersActive || showFilterPanel
|
|
? 'border-primary text-primary bg-primary/5'
|
|
: 'border-border text-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
<Filter className="w-3.5 h-3.5" />
|
|
Filter
|
|
{filtersActive && <span className="w-1.5 h-1.5 rounded-full bg-primary" />}
|
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
{showFilterPanel && (
|
|
<div className="absolute left-0 top-full mt-1.5 z-30 w-[260px] crm-radius-section border border-border bg-card shadow-lg p-3 space-y-3">
|
|
<div>
|
|
<label className={labelClass}>Orders</label>
|
|
<CustomSelect
|
|
value={draftFilters.orders}
|
|
onChange={(orders) =>
|
|
setDraftFilters((prev) => ({ ...prev, orders: orders as OrderFilter }))
|
|
}
|
|
aria-label="Orders"
|
|
options={[
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'has_orders', label: 'Has orders' },
|
|
{ value: 'no_orders', label: 'No orders' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Phone</label>
|
|
<CustomSelect
|
|
value={draftFilters.phone}
|
|
onChange={(phone) =>
|
|
setDraftFilters((prev) => ({ ...prev, phone: phone as PhoneFilter }))
|
|
}
|
|
aria-label="Phone"
|
|
options={[
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'has_phone', label: 'Has phone' },
|
|
{ value: 'no_phone', label: 'No phone' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Credits</label>
|
|
<CustomSelect
|
|
value={draftFilters.credits}
|
|
onChange={(credits) =>
|
|
setDraftFilters((prev) => ({ ...prev, credits: credits as CreditFilter }))
|
|
}
|
|
aria-label="Credits"
|
|
options={[
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'has_credits', label: 'Has credits' },
|
|
{ value: 'no_credits', label: 'No credits' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraftFilters(EMPTY_FILTERS);
|
|
setActiveFilters(EMPTY_FILTERS);
|
|
setShowFilterPanel(false);
|
|
}}
|
|
className={secondaryButton}
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActiveFilters(draftFilters);
|
|
setShowFilterPanel(false);
|
|
}}
|
|
className={primaryButton}
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative w-full sm:w-[240px]">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search customers"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full h-9 pl-8 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="py-16 flex justify-center border-t border-border">
|
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
|
</div>
|
|
) : filteredCustomers.length === 0 ? (
|
|
<div className="py-16 text-center text-[13px] text-muted-foreground border-t border-border">
|
|
{search || filtersActive ? 'No customers match your search' : 'No customers yet'}
|
|
</div>
|
|
) : viewMode === 'grid' ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 p-4 border-t border-border">
|
|
{pager.items.map((customer) => (
|
|
<div key={customer.customer_id} className="crm-radius-section border border-border bg-card p-3.5 min-w-0">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<div className="w-9 h-9 crm-radius-icon bg-primary/10 text-primary text-[12px] font-bold flex items-center justify-center shrink-0">
|
|
{customer.first_name.charAt(0).toUpperCase()}
|
|
{customer.last_name.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[13px] font-semibold text-foreground truncate">
|
|
{customer.first_name} {customer.last_name}
|
|
</p>
|
|
<p className="text-[11px] text-muted-foreground truncate">{customer.email}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleInspectCustomer(customer.customer_id)}
|
|
className="w-8 h-8 crm-radius-control hover:bg-muted cursor-pointer flex items-center justify-center shrink-0"
|
|
title="View details"
|
|
>
|
|
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
<div className="mt-3 space-y-1.5 text-[12px] text-muted-foreground">
|
|
<p className="flex items-center gap-1.5 truncate">
|
|
<Phone className="w-3.5 h-3.5 shrink-0" />
|
|
{customer.phone || '—'}
|
|
</p>
|
|
<p className="flex items-center gap-1.5 truncate">
|
|
<Mail className="w-3.5 h-3.5 shrink-0" />
|
|
{customer.email}
|
|
</p>
|
|
</div>
|
|
<div className="mt-3 flex items-center justify-between gap-2">
|
|
<span className="inline-flex items-center px-2.5 py-1 crm-radius-badge bg-warning text-white text-[11px] font-semibold">
|
|
{customer.credit_balance || 0} credits
|
|
</span>
|
|
<span className="text-[13px] font-semibold text-foreground">
|
|
{formatCurrency(customer.total_spent || 0)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto border-t border-border">
|
|
<table className="crm-data-table min-w-[860px]">
|
|
<thead>
|
|
<tr className="border-b border-border bg-gray-50">
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Customer</th>
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Contact</th>
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Credits</th>
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Joined</th>
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700 text-right">Spent</th>
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{pager.items.map((customer) => {
|
|
const isExpanded = expandedRows.has(customer.customer_id);
|
|
return (
|
|
<React.Fragment key={customer.customer_id}>
|
|
<tr
|
|
className={`border-b border-border hover:bg-muted/10 cursor-pointer transition-colors ${
|
|
isExpanded ? 'bg-primary/5' : ''
|
|
}`}
|
|
onClick={() => toggleRow(customer.customer_id)}
|
|
>
|
|
<td className="px-5 py-3.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground">
|
|
{isExpanded ? (
|
|
<ChevronUp className="w-3.5 h-3.5" />
|
|
) : (
|
|
<ChevronDown className="w-3.5 h-3.5" />
|
|
)}
|
|
</span>
|
|
<span className="text-[13px] font-semibold text-foreground">
|
|
{customer.first_name} {customer.last_name}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-3.5">
|
|
<div className="text-[13px] text-foreground">{customer.email}</div>
|
|
<div className="text-[11px] text-muted-foreground">{customer.phone || '—'}</div>
|
|
</td>
|
|
<td className="px-5 py-3.5">
|
|
<span className="inline-flex items-center px-2.5 py-1 crm-radius-badge bg-warning text-white text-[11px] font-semibold">
|
|
{customer.credit_balance || 0} credits
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
|
|
{format(new Date(customer.created_at), 'dd-MMM-yyyy')}
|
|
</td>
|
|
<td className="px-5 py-3.5 text-right text-[13px] font-semibold text-foreground">
|
|
{formatCurrency(customer.total_spent || 0)}
|
|
</td>
|
|
<td className="px-5 py-3.5 text-right">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleInspectCustomer(customer.customer_id);
|
|
}}
|
|
className="w-8 h-8 crm-radius-control inline-flex items-center justify-center hover:bg-muted cursor-pointer"
|
|
title="View details"
|
|
>
|
|
<Eye className="w-4 h-4 text-muted-foreground" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{isExpanded && (
|
|
<tr>
|
|
<td colSpan={6} className="p-0 border-b border-border bg-muted/10">
|
|
<PurchaseHistory customerId={customer.customer_id} />
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
<TablePagination {...pager} />
|
|
</div>
|
|
|
|
<div className={`lg:col-span-1 ${dataCardShell} p-5 min-h-[400px]`}>
|
|
{selectedCustomerId === null ? (
|
|
<div className="h-full min-h-[320px] flex flex-col justify-center items-center text-center p-6">
|
|
<Users className="w-12 h-12 text-muted-foreground/30 mb-4" />
|
|
<p className="text-[14px] font-semibold text-foreground">Customer details</p>
|
|
<p className="text-[13px] text-muted-foreground max-w-xs mt-1">
|
|
Select a customer to view saved addresses and purchase history.
|
|
</p>
|
|
</div>
|
|
) : detailLoading ? (
|
|
<div className="h-full py-20 flex justify-center items-center">
|
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
|
</div>
|
|
) : detailData === null ? (
|
|
<div className="text-center py-20 text-[13px] text-destructive">Failed to load customer details.</div>
|
|
) : (
|
|
<div className="space-y-5">
|
|
<div className="flex justify-between items-start gap-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-12 h-12 crm-radius-icon bg-primary/10 text-primary flex items-center justify-center font-bold text-[15px] shrink-0">
|
|
{detailData.first_name.charAt(0).toUpperCase()}
|
|
{detailData.last_name.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="text-[15px] font-semibold text-foreground truncate">
|
|
{detailData.first_name} {detailData.last_name}
|
|
</h3>
|
|
<p className="text-[13px] text-muted-foreground truncate">{detailData.email}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedCustomerId(null)}
|
|
className="w-8 h-8 crm-radius-control hover:bg-muted text-muted-foreground cursor-pointer flex items-center justify-center shrink-0"
|
|
aria-label="Close details"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-4 space-y-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase mb-2 flex items-center gap-1.5">
|
|
<MapPin className="w-3.5 h-3.5" />
|
|
Addresses ({detailData.addresses.length})
|
|
</p>
|
|
{detailData.addresses.length === 0 ? (
|
|
<p className="text-[13px] text-muted-foreground">No addresses saved.</p>
|
|
) : (
|
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
|
{detailData.addresses.map((a) => (
|
|
<div
|
|
key={a.address_id}
|
|
className={`p-3 border crm-radius-section text-[13px] ${
|
|
a.is_default ? 'border-primary/30 bg-primary/5' : 'border-border'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-1 gap-2">
|
|
<span className="font-semibold text-foreground">{a.full_name}</span>
|
|
{a.is_default && (
|
|
<span className="inline-flex items-center px-2 py-0.5 crm-radius-badge bg-primary text-white text-[11px] font-semibold">
|
|
Default
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-muted-foreground">
|
|
{a.street_address}, {a.city}, {a.state} - {a.pincode}
|
|
</p>
|
|
<p className="text-muted-foreground mt-1">Phone: {a.phone}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase mb-2 flex items-center gap-1.5">
|
|
<Package className="w-3.5 h-3.5" />
|
|
Orders ({detailData.orders.length})
|
|
</p>
|
|
{detailData.orders.length === 0 ? (
|
|
<p className="text-[13px] text-muted-foreground">No purchases recorded.</p>
|
|
) : (
|
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
|
{detailData.orders.map((o) => (
|
|
<div
|
|
key={o.order_id}
|
|
className="p-3 border border-border crm-radius-section flex items-center justify-between gap-2"
|
|
>
|
|
<div>
|
|
<span className="text-[13px] font-semibold text-foreground block">{o.order_no}</span>
|
|
<span className="text-[12px] text-muted-foreground block">
|
|
{format(new Date(o.created_at), 'dd-MMM-yyyy')}
|
|
</span>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="text-[13px] font-semibold text-foreground block">
|
|
{formatCurrency(o.final_amount)}
|
|
</span>
|
|
<span className="text-[11px] font-semibold text-primary block">{o.status}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|