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:
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