feat(customers): Add customer detail page with stats and orders
Created comprehensive customer detail page: Features: ✅ Customer Info Card: - Avatar with User icon - Name and email display - Member/Guest badge - Stats grid: Total Orders, Total Spent, Registered date ✅ Contact Information: - Email address - Phone number (if available) ✅ Billing Address: - Full address display - City, state, postcode - Country ✅ Recent Orders Section: - Shows last 10 orders - Order number, status badge, date - Total amount and item count - Clickable to view order details - Link to view all orders ✅ Page Header: - Customer name as title - Back button (to customers list) - Edit button (to edit page) ✅ Navigation: - Name in index → Detail page (/customers/:id) - Edit button → Edit page (/customers/:id/edit) - Order cards → Order detail (/orders/:id) ✅ Loading & Error States: - Skeleton loaders while fetching - Error card with retry option - Empty state for no orders Technical: - Uses OrdersApi to fetch customer orders - Filters completed/processing orders for stats - Responsive grid layout - Consistent with other detail pages (Orders) - TypeScript with proper type annotations Files: - Created: routes/Customers/Detail.tsx - Updated: App.tsx (added route) - Updated: routes/Customers/index.tsx (links to detail) Result: Complete customer profile view with order history!
This commit is contained in:
@@ -24,6 +24,7 @@ import CouponEdit from '@/routes/Coupons/Edit';
|
|||||||
import CustomersIndex from '@/routes/Customers';
|
import CustomersIndex from '@/routes/Customers';
|
||||||
import CustomerNew from '@/routes/Customers/New';
|
import CustomerNew from '@/routes/Customers/New';
|
||||||
import CustomerEdit from '@/routes/Customers/Edit';
|
import CustomerEdit from '@/routes/Customers/Edit';
|
||||||
|
import CustomerDetail from '@/routes/Customers/Detail';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Maximize2, Minimize2, Loader2 } from 'lucide-react';
|
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Maximize2, Minimize2, Loader2 } from 'lucide-react';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
@@ -486,6 +487,7 @@ function AppRoutes() {
|
|||||||
<Route path="/customers" element={<CustomersIndex />} />
|
<Route path="/customers" element={<CustomersIndex />} />
|
||||||
<Route path="/customers/new" element={<CustomerNew />} />
|
<Route path="/customers/new" element={<CustomerNew />} />
|
||||||
<Route path="/customers/:id/edit" element={<CustomerEdit />} />
|
<Route path="/customers/:id/edit" element={<CustomerEdit />} />
|
||||||
|
<Route path="/customers/:id" element={<CustomerDetail />} />
|
||||||
|
|
||||||
{/* More */}
|
{/* More */}
|
||||||
<Route path="/more" element={<MorePage />} />
|
<Route path="/more" element={<MorePage />} />
|
||||||
|
|||||||
252
admin-spa/src/routes/Customers/Detail.tsx
Normal file
252
admin-spa/src/routes/Customers/Detail.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { __ } from '@/lib/i18n';
|
||||||
|
import { CustomersApi } from '@/lib/api/customers';
|
||||||
|
import { OrdersApi } from '@/lib/api';
|
||||||
|
import { showErrorToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ArrowLeft, Edit, Mail, Calendar, ShoppingBag, DollarSign, User, MapPin } from 'lucide-react';
|
||||||
|
import { formatMoney } from '@/lib/currency';
|
||||||
|
|
||||||
|
export default function CustomerDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const customerId = parseInt(id || '0', 10);
|
||||||
|
|
||||||
|
// Fetch customer data
|
||||||
|
const customerQuery = useQuery({
|
||||||
|
queryKey: ['customer', customerId],
|
||||||
|
queryFn: () => CustomersApi.get(customerId),
|
||||||
|
enabled: !!customerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch customer orders
|
||||||
|
const ordersQuery = useQuery({
|
||||||
|
queryKey: ['customer-orders', customerId],
|
||||||
|
queryFn: () => OrdersApi.list({ customer_id: customerId, per_page: 100 }),
|
||||||
|
enabled: !!customerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = customerQuery.data;
|
||||||
|
const orders = ordersQuery.data?.orders || [];
|
||||||
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
|
|
||||||
|
// Page header
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!customer) {
|
||||||
|
clearPageHeader();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => navigate('/customers')}>
|
||||||
|
{__('Back')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => navigate(`/customers/${customerId}/edit`)}>
|
||||||
|
{__('Edit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
setPageHeader(
|
||||||
|
customer.display_name || `${customer.first_name} ${customer.last_name}`,
|
||||||
|
actions
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => clearPageHeader();
|
||||||
|
}, [customer, customerId, navigate, setPageHeader, clearPageHeader]);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (customerQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (customerQuery.isError || !customer) {
|
||||||
|
return (
|
||||||
|
<ErrorCard
|
||||||
|
title={__('Failed to load customer')}
|
||||||
|
message={getPageLoadErrorMessage(customerQuery.error)}
|
||||||
|
onRetry={() => customerQuery.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate stats from orders
|
||||||
|
const completedOrders = orders.filter((o: any) => o.status === 'completed' || o.status === 'processing');
|
||||||
|
const totalSpent = completedOrders.reduce((sum: number, order: any) => sum + parseFloat(order.total || '0'), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-6">
|
||||||
|
{/* Customer Info Card */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<User className="w-8 h-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">
|
||||||
|
{customer.display_name || `${customer.first_name} ${customer.last_name}`}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">{customer.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
customer.role === 'customer' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{customer.role === 'customer' ? __('Member') : __('Guest')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="p-4 rounded-lg bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||||
|
<ShoppingBag className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{__('Total Orders')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">{customer.stats?.total_orders || 0}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{__('Total Spent')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{customer.stats?.total_spent ? formatMoney(customer.stats.total_spent) : formatMoney(0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 rounded-lg bg-muted/50">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{__('Registered')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-medium">
|
||||||
|
{new Date(customer.registered).toLocaleDateString('id-ID', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contact & Address Info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Contact Info */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5" />
|
||||||
|
{__('Contact Information')}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">{__('Email')}</div>
|
||||||
|
<div className="font-medium">{customer.email}</div>
|
||||||
|
</div>
|
||||||
|
{customer.billing?.phone && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">{__('Phone')}</div>
|
||||||
|
<div className="font-medium">{customer.billing.phone}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Billing Address */}
|
||||||
|
{customer.billing && (customer.billing.address_1 || customer.billing.city) && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5" />
|
||||||
|
{__('Billing Address')}
|
||||||
|
</h3>
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
{customer.billing.address_1 && <div>{customer.billing.address_1}</div>}
|
||||||
|
{customer.billing.address_2 && <div>{customer.billing.address_2}</div>}
|
||||||
|
<div>
|
||||||
|
{[customer.billing.city, customer.billing.state, customer.billing.postcode]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')}
|
||||||
|
</div>
|
||||||
|
{customer.billing.country && <div>{customer.billing.country}</div>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Orders */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">{__('Recent Orders')}</h3>
|
||||||
|
{orders.length > 0 && (
|
||||||
|
<Link to={`/orders?customer_id=${customerId}`} className="text-sm text-primary hover:underline">
|
||||||
|
{__('View all orders')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ordersQuery.isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
</div>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<ShoppingBag className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>{__('No orders yet')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{orders.slice(0, 10).map((order: any) => (
|
||||||
|
<Link
|
||||||
|
key={order.id}
|
||||||
|
to={`/orders/${order.id}`}
|
||||||
|
className="block p-4 rounded-lg border border-border hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-medium">#{order.number}</span>
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
order.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||||
|
order.status === 'processing' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
order.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{order.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">
|
||||||
|
{new Date(order.date_created).toLocaleDateString('id-ID')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-bold">{formatMoney(parseFloat(order.total || '0'))}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{order.line_items?.length || 0} {__('items')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user