feat(frontend): Complete Customer module with vertical tab forms

Implemented full Customer CRUD following PROJECT_SOP.md standards

1. Customers Index Page (index.tsx):
 List with pagination (20 per page)
 Search by name/email
 Bulk delete with confirmation
 Refresh button (required by SOP)
 Desktop: Table with columns (Name, Email, Orders, Total Spent, Registered)
 Mobile: Cards with same data
 Empty state with CTA
 Proper toolbar styling (red delete button, refresh button)
 FAB config for 'New Customer'

2. CustomerForm Component (CustomerForm.tsx):
 Vertical tabs: Personal Data | Billing Address | Shipping Address
 Personal Data tab:
   - First/Last name (required)
   - Email (required)
   - Username (auto-generated from email if empty)
   - Password (auto-generated if empty for new)
   - Send welcome email checkbox (create only)
 Billing Address tab:
   - Company, Address 1/2, City, State, Postcode, Country, Phone
 Shipping Address tab:
   - Same fields as billing
   - 'Same as billing' checkbox with auto-copy
 Mobile: Horizontal tabs
 Desktop: Vertical sidebar tabs
 Proper form validation

3. Customer New Page (New.tsx):
 Uses CustomerForm in create mode
 Page header with Back + Create buttons
 Form ref for header submit
 Success toast with customer name
 Redirects to list after create
 Error handling

4. Customer Edit Page (Edit.tsx):
 Uses CustomerForm in edit mode
 Loads customer data
 Page header with Back + Save buttons
 Loading skeleton
 Error card with retry
 Success toast
 Redirects to list after save

5. Routes (App.tsx):
 /customers → Index
 /customers/new → New
 /customers/:id/edit → Edit
 Consistent with products/coupons pattern

Features:
- Full WooCommerce integration
- Billing/shipping address management
- Order statistics display
- Email uniqueness validation
- Password auto-generation
- Welcome email option
- Responsive design (mobile + desktop)
- Vertical tabs for better UX
- Follows all PROJECT_SOP.md standards

Next: Testing and verification
This commit is contained in:
dwindown
2025-11-20 22:55:45 +07:00
parent 8254e3e712
commit 921c1b6f80
5 changed files with 868 additions and 4 deletions

View File

@@ -0,0 +1,429 @@
import React, { useState, useEffect } from 'react';
import { __ } from '@/lib/i18n';
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { User, MapPin, Home } from 'lucide-react';
import type { Customer, CustomerFormData } from '@/lib/api/customers';
type Props = {
mode: 'create' | 'edit';
initial?: Customer | null;
onSubmit: (data: CustomerFormData) => Promise<void> | void;
className?: string;
formRef?: React.RefObject<HTMLFormElement>;
hideSubmitButton?: boolean;
};
export function CustomerForm({
mode,
initial,
onSubmit,
className,
formRef,
hideSubmitButton = false,
}: Props) {
// Personal data
const [email, setEmail] = useState(initial?.email || '');
const [firstName, setFirstName] = useState(initial?.first_name || '');
const [lastName, setLastName] = useState(initial?.last_name || '');
const [username, setUsername] = useState(initial?.username || '');
const [password, setPassword] = useState('');
const [sendEmail, setSendEmail] = useState(mode === 'create');
// Billing address
const [billingCompany, setBillingCompany] = useState(initial?.billing?.company || '');
const [billingAddress1, setBillingAddress1] = useState(initial?.billing?.address_1 || '');
const [billingAddress2, setBillingAddress2] = useState(initial?.billing?.address_2 || '');
const [billingCity, setBillingCity] = useState(initial?.billing?.city || '');
const [billingState, setBillingState] = useState(initial?.billing?.state || '');
const [billingPostcode, setBillingPostcode] = useState(initial?.billing?.postcode || '');
const [billingCountry, setBillingCountry] = useState(initial?.billing?.country || '');
const [billingPhone, setBillingPhone] = useState(initial?.billing?.phone || '');
// Shipping address
const [shippingCompany, setShippingCompany] = useState(initial?.shipping?.company || '');
const [shippingAddress1, setShippingAddress1] = useState(initial?.shipping?.address_1 || '');
const [shippingAddress2, setShippingAddress2] = useState(initial?.shipping?.address_2 || '');
const [shippingCity, setShippingCity] = useState(initial?.shipping?.city || '');
const [shippingState, setShippingState] = useState(initial?.shipping?.state || '');
const [shippingPostcode, setShippingPostcode] = useState(initial?.shipping?.postcode || '');
const [shippingCountry, setShippingCountry] = useState(initial?.shipping?.country || '');
const [copyBilling, setCopyBilling] = useState(false);
// Submitting state
const [submitting, setSubmitting] = useState(false);
// Copy billing to shipping
useEffect(() => {
if (copyBilling) {
setShippingCompany(billingCompany);
setShippingAddress1(billingAddress1);
setShippingAddress2(billingAddress2);
setShippingCity(billingCity);
setShippingState(billingState);
setShippingPostcode(billingPostcode);
setShippingCountry(billingCountry);
}
}, [copyBilling, billingCompany, billingAddress1, billingAddress2, billingCity, billingState, billingPostcode, billingCountry]);
// Handle submit
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const data: CustomerFormData = {
email,
first_name: firstName,
last_name: lastName,
billing: {
first_name: firstName,
last_name: lastName,
company: billingCompany,
address_1: billingAddress1,
address_2: billingAddress2,
city: billingCity,
state: billingState,
postcode: billingPostcode,
country: billingCountry,
phone: billingPhone,
},
shipping: {
first_name: firstName,
last_name: lastName,
company: shippingCompany,
address_1: shippingAddress1,
address_2: shippingAddress2,
city: shippingCity,
state: shippingState,
postcode: shippingPostcode,
country: shippingCountry,
},
};
// Add username and password for new customers
if (mode === 'create') {
if (username) data.username = username;
if (password) data.password = password;
data.send_email = sendEmail;
} else if (password) {
// Only include password if changing it
data.password = password;
}
try {
setSubmitting(true);
await onSubmit(data);
} finally {
setSubmitting(false);
}
};
// Define tabs
const tabs = [
{ id: 'personal', label: __('Personal Data'), icon: <User className="w-4 h-4" /> },
{ id: 'billing', label: __('Billing Address'), icon: <MapPin className="w-4 h-4" /> },
{ id: 'shipping', label: __('Shipping Address'), icon: <Home className="w-4 h-4" /> },
];
return (
<form ref={formRef} onSubmit={handleSubmit} className={className}>
<VerticalTabForm tabs={tabs}>
{/* Personal Data */}
<FormSection id="personal">
<Card>
<CardHeader>
<CardTitle>{__('Personal Information')}</CardTitle>
<CardDescription>
{__('Basic customer information and account details')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">{__('First Name')} *</Label>
<Input
id="first_name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="last_name">{__('Last Name')} *</Label>
<Input
id="last_name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">{__('Email')} *</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{mode === 'create' && (
<div className="space-y-2">
<Label htmlFor="username">{__('Username')}</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={__('Leave empty to use email')}
/>
<p className="text-xs text-muted-foreground">
{__('Username will be generated from email if left empty')}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="password">
{mode === 'create' ? __('Password') : __('New Password')}
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={mode === 'create' ? __('Leave empty to auto-generate') : __('Leave empty to keep current')}
/>
{mode === 'create' && (
<p className="text-xs text-muted-foreground">
{__('A secure password will be generated if left empty')}
</p>
)}
</div>
{mode === 'create' && (
<div className="flex items-center space-x-2">
<Checkbox
id="send_email"
checked={sendEmail}
onCheckedChange={(checked) => setSendEmail(Boolean(checked))}
/>
<Label htmlFor="send_email" className="cursor-pointer">
{__('Send welcome email with login credentials')}
</Label>
</div>
)}
</CardContent>
</Card>
</FormSection>
{/* Billing Address */}
<FormSection id="billing">
<Card>
<CardHeader>
<CardTitle>{__('Billing Address')}</CardTitle>
<CardDescription>
{__('Customer billing information')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="billing_company">{__('Company')}</Label>
<Input
id="billing_company"
value={billingCompany}
onChange={(e) => setBillingCompany(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="billing_address_1">{__('Address Line 1')}</Label>
<Input
id="billing_address_1"
value={billingAddress1}
onChange={(e) => setBillingAddress1(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="billing_address_2">{__('Address Line 2')}</Label>
<Input
id="billing_address_2"
value={billingAddress2}
onChange={(e) => setBillingAddress2(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="billing_city">{__('City')}</Label>
<Input
id="billing_city"
value={billingCity}
onChange={(e) => setBillingCity(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="billing_state">{__('State / Province')}</Label>
<Input
id="billing_state"
value={billingState}
onChange={(e) => setBillingState(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="billing_postcode">{__('Postcode / ZIP')}</Label>
<Input
id="billing_postcode"
value={billingPostcode}
onChange={(e) => setBillingPostcode(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="billing_country">{__('Country')}</Label>
<Input
id="billing_country"
value={billingCountry}
onChange={(e) => setBillingCountry(e.target.value)}
placeholder="ID"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="billing_phone">{__('Phone')}</Label>
<Input
id="billing_phone"
type="tel"
value={billingPhone}
onChange={(e) => setBillingPhone(e.target.value)}
/>
</div>
</CardContent>
</Card>
</FormSection>
{/* Shipping Address */}
<FormSection id="shipping">
<Card>
<CardHeader>
<CardTitle>{__('Shipping Address')}</CardTitle>
<CardDescription>
{__('Customer shipping information')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2 mb-4">
<Checkbox
id="copy_billing"
checked={copyBilling}
onCheckedChange={(checked) => setCopyBilling(Boolean(checked))}
/>
<Label htmlFor="copy_billing" className="cursor-pointer">
{__('Same as billing address')}
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="shipping_company">{__('Company')}</Label>
<Input
id="shipping_company"
value={shippingCompany}
onChange={(e) => setShippingCompany(e.target.value)}
disabled={copyBilling}
/>
</div>
<div className="space-y-2">
<Label htmlFor="shipping_address_1">{__('Address Line 1')}</Label>
<Input
id="shipping_address_1"
value={shippingAddress1}
onChange={(e) => setShippingAddress1(e.target.value)}
disabled={copyBilling}
/>
</div>
<div className="space-y-2">
<Label htmlFor="shipping_address_2">{__('Address Line 2')}</Label>
<Input
id="shipping_address_2"
value={shippingAddress2}
onChange={(e) => setShippingAddress2(e.target.value)}
disabled={copyBilling}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="shipping_city">{__('City')}</Label>
<Input
id="shipping_city"
value={shippingCity}
onChange={(e) => setShippingCity(e.target.value)}
disabled={copyBilling}
/>
</div>
<div className="space-y-2">
<Label htmlFor="shipping_state">{__('State / Province')}</Label>
<Input
id="shipping_state"
value={shippingState}
onChange={(e) => setShippingState(e.target.value)}
disabled={copyBilling}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="shipping_postcode">{__('Postcode / ZIP')}</Label>
<Input
id="shipping_postcode"
value={shippingPostcode}
onChange={(e) => setShippingPostcode(e.target.value)}
disabled={copyBilling}
/>
</div>
<div className="space-y-2">
<Label htmlFor="shipping_country">{__('Country')}</Label>
<Input
id="shipping_country"
value={shippingCountry}
onChange={(e) => setShippingCountry(e.target.value)}
placeholder="ID"
disabled={copyBilling}
/>
</div>
</div>
</CardContent>
</Card>
</FormSection>
</VerticalTabForm>
{!hideSubmitButton && (
<div className="mt-6">
<Button type="submit" disabled={submitting} className="w-full md:w-auto">
{submitting
? (mode === 'create' ? __('Creating...') : __('Saving...'))
: (mode === 'create' ? __('Create Customer') : __('Save Changes'))
}
</Button>
</div>
)}
</form>
);
}

View File

@@ -0,0 +1,104 @@
import React, { useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import { __ } from '@/lib/i18n';
import { CustomersApi, type CustomerFormData } from '@/lib/api/customers';
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
import { useFABConfig } from '@/hooks/useFABConfig';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { CustomerForm } from './CustomerForm';
import { Button } from '@/components/ui/button';
import { ErrorCard } from '@/components/ErrorCard';
import { Skeleton } from '@/components/ui/skeleton';
export default function CustomerEdit() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const formRef = useRef<HTMLFormElement>(null);
const { setPageHeader, clearPageHeader } = usePageHeader();
// Hide FAB on edit customer page
useFABConfig('none');
// Fetch customer
const customerQuery = useQuery({
queryKey: ['customers', id],
queryFn: () => CustomersApi.get(Number(id)),
enabled: !!id,
});
// Update mutation
const updateMutation = useMutation({
mutationFn: (data: CustomerFormData) => CustomersApi.update(Number(id), data),
onSuccess: (customer) => {
showSuccessToast(__('Customer updated successfully'));
queryClient.invalidateQueries({ queryKey: ['customers'] });
queryClient.invalidateQueries({ queryKey: ['customers', id] });
navigate('/customers');
},
onError: (error: any) => {
showErrorToast(error);
},
});
const handleSubmit = async (data: CustomerFormData) => {
await updateMutation.mutateAsync(data);
};
// Set page header with back button and save button
useEffect(() => {
const actions = (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => navigate('/customers')}>
{__('Back')}
</Button>
<Button
size="sm"
onClick={() => formRef.current?.requestSubmit()}
disabled={updateMutation.isPending || customerQuery.isLoading}
>
{updateMutation.isPending ? __('Saving...') : __('Save Changes')}
</Button>
</div>
);
setPageHeader(__('Edit Customer'), actions);
return () => clearPageHeader();
}, [updateMutation.isPending, customerQuery.isLoading, setPageHeader, clearPageHeader, navigate]);
// Loading state
if (customerQuery.isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
<Skeleton className="h-32 w-full" />
</div>
);
}
// Error state
if (customerQuery.isError) {
return (
<ErrorCard
title={__('Failed to load customer')}
message={getPageLoadErrorMessage(customerQuery.error)}
onRetry={() => customerQuery.refetch()}
/>
);
}
const customer = customerQuery.data;
return (
<div>
<CustomerForm
mode="edit"
initial={customer}
onSubmit={handleSubmit}
formRef={formRef}
hideSubmitButton={true}
/>
</div>
);
}

View File

@@ -0,0 +1,68 @@
import React, { useRef, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { __ } from '@/lib/i18n';
import { CustomersApi, type CustomerFormData } from '@/lib/api/customers';
import { showErrorToast, showSuccessToast } from '@/lib/errorHandling';
import { useFABConfig } from '@/hooks/useFABConfig';
import { usePageHeader } from '@/contexts/PageHeaderContext';
import { CustomerForm } from './CustomerForm';
import { Button } from '@/components/ui/button';
export default function CustomerNew() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const formRef = useRef<HTMLFormElement>(null);
const { setPageHeader, clearPageHeader } = usePageHeader();
// Hide FAB on new customer page
useFABConfig('none');
// Create mutation
const createMutation = useMutation({
mutationFn: (data: CustomerFormData) => CustomersApi.create(data),
onSuccess: (customer) => {
showSuccessToast(__('Customer created successfully'), `${customer.display_name} has been added`);
queryClient.invalidateQueries({ queryKey: ['customers'] });
navigate('/customers');
},
onError: (error: any) => {
showErrorToast(error);
},
});
const handleSubmit = async (data: CustomerFormData) => {
await createMutation.mutateAsync(data);
};
// Set page header with back button and create button
useEffect(() => {
const actions = (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => navigate('/customers')}>
{__('Back')}
</Button>
<Button
size="sm"
onClick={() => formRef.current?.requestSubmit()}
disabled={createMutation.isPending}
>
{createMutation.isPending ? __('Creating...') : __('Create Customer')}
</Button>
</div>
);
setPageHeader(__('New Customer'), actions);
return () => clearPageHeader();
}, [createMutation.isPending, setPageHeader, clearPageHeader, navigate]);
return (
<div>
<CustomerForm
mode="create"
onSubmit={handleSubmit}
formRef={formRef}
hideSubmitButton={true}
/>
</div>
);
}

View File

@@ -1,11 +1,270 @@
import React from 'react';
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { __ } from '@/lib/i18n';
import { CustomersApi, type Customer } from '@/lib/api/customers';
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
import { useFABConfig } from '@/hooks/useFABConfig';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ErrorCard } from '@/components/ErrorCard';
import { Skeleton } from '@/components/ui/skeleton';
import { RefreshCw, Trash2, Search, User } from 'lucide-react';
import { formatMoney } from '@/lib/currency';
export default function CustomersIndex() {
const navigate = useNavigate();
const queryClient = useQueryClient();
// State
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// FAB config for "New Customer" button
useFABConfig('customers');
// Fetch customers
const customersQuery = useQuery({
queryKey: ['customers', page, search],
queryFn: () => CustomersApi.list({ page, per_page: 20, search }),
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (ids: number[]) => {
await Promise.all(ids.map(id => CustomersApi.delete(id)));
},
onSuccess: () => {
showSuccessToast(__('Customers deleted successfully'));
setSelectedIds([]);
queryClient.invalidateQueries({ queryKey: ['customers'] });
},
onError: (error: any) => {
showErrorToast(error);
},
});
// Handlers
const toggleSelection = (id: number) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
const toggleAll = () => {
if (selectedIds.length === customers.length) {
setSelectedIds([]);
} else {
setSelectedIds(customers.map(c => c.id));
}
};
const handleDelete = () => {
if (selectedIds.length === 0) return;
if (!confirm(__('Are you sure you want to delete the selected customers? This action cannot be undone.'))) return;
deleteMutation.mutate(selectedIds);
};
const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
};
// Data
const customers = customersQuery.data?.data || [];
const pagination = customersQuery.data?.pagination;
// Loading state
if (customersQuery.isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
// Error state
if (customersQuery.isError) {
return (
<ErrorCard
title={__('Failed to load customers')}
message={getPageLoadErrorMessage(customersQuery.error)}
onRetry={() => customersQuery.refetch()}
/>
);
}
return (
<div>
<h1 className="text-xl font-semibold mb-3">{__('Customers')}</h1>
<p className="opacity-70">{__('Coming soon — SPA customer list.')}</p>
<div className="space-y-4">
{/* Toolbar */}
<div className="hidden md:block rounded-lg border border-border p-4 bg-card">
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
{/* Left: Bulk Actions */}
<div className="flex gap-3">
{selectedIds.length > 0 && (
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
{__('Delete')} ({selectedIds.length})
</button>
)}
<button
onClick={handleRefresh}
disabled={customersQuery.isFetching}
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${customersQuery.isFetching ? 'animate-spin' : ''}`} />
{__('Refresh')}
</button>
</div>
{/* Right: Search */}
<div className="flex gap-3 items-center">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="search"
placeholder={__('Search customers...')}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="pl-9 w-64"
/>
</div>
</div>
</div>
</div>
{/* Desktop: Table */}
<div className="hidden md:block rounded-lg border overflow-hidden">
<table className="w-full">
<thead className="bg-muted/50">
<tr className="border-b">
<th className="w-12 p-3">
<Checkbox
checked={selectedIds.length === customers.length && customers.length > 0}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
/>
</th>
<th className="text-left p-3 font-medium">{__('Customer')}</th>
<th className="text-left p-3 font-medium">{__('Email')}</th>
<th className="text-left p-3 font-medium">{__('Orders')}</th>
<th className="text-left p-3 font-medium">{__('Total Spent')}</th>
<th className="text-left p-3 font-medium">{__('Registered')}</th>
</tr>
</thead>
<tbody>
{customers.length === 0 ? (
<tr>
<td colSpan={6} className="p-8 text-center text-muted-foreground">
<User className="w-12 h-12 mx-auto mb-2 opacity-50" />
{search ? __('No customers found matching your search') : __('No customers yet')}
{!search && (
<p className="text-sm mt-1">
<Link to="/customers/new" className="text-primary hover:underline">
{__('Create your first customer')}
</Link>
</p>
)}
</td>
</tr>
) : (
customers.map((customer) => (
<tr key={customer.id} className="border-b hover:bg-muted/30 last:border-0">
<td className="p-3">
<Checkbox
checked={selectedIds.includes(customer.id)}
onCheckedChange={() => toggleSelection(customer.id)}
aria-label={__('Select customer')}
/>
</td>
<td className="p-3">
<Link to={`/customers/${customer.id}/edit`} className="font-medium hover:underline">
{customer.display_name || `${customer.first_name} ${customer.last_name}`}
</Link>
</td>
<td className="p-3 text-sm text-muted-foreground">{customer.email}</td>
<td className="p-3 text-sm">{customer.stats?.total_orders || 0}</td>
<td className="p-3 text-sm font-medium">
{customer.stats?.total_spent ? formatMoney(customer.stats.total_spent) : '—'}
</td>
<td className="p-3 text-sm text-muted-foreground">
{new Date(customer.registered).toLocaleDateString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Mobile: Cards */}
<div className="md:hidden space-y-2">
{customers.length === 0 ? (
<Card className="p-8 text-center text-muted-foreground">
<User className="w-12 h-12 mx-auto mb-2 opacity-50" />
{search ? __('No customers found') : __('No customers yet')}
</Card>
) : (
customers.map((customer) => (
<Card key={customer.id} className="p-4">
<div className="flex items-start gap-3">
<Checkbox
checked={selectedIds.includes(customer.id)}
onCheckedChange={() => toggleSelection(customer.id)}
/>
<div className="flex-1 min-w-0">
<Link to={`/customers/${customer.id}/edit`} className="font-medium hover:underline block">
{customer.display_name || `${customer.first_name} ${customer.last_name}`}
</Link>
<p className="text-sm text-muted-foreground truncate">{customer.email}</p>
<div className="flex gap-4 mt-2 text-sm">
<span>{customer.stats?.total_orders || 0} {__('orders')}</span>
<span className="font-medium">
{customer.stats?.total_spent ? formatMoney(customer.stats.total_spent) : '—'}
</span>
</div>
</div>
</div>
</Card>
))
)}
</div>
{/* Pagination */}
{pagination && pagination.total_pages > 1 && (
<div className="flex justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1 || customersQuery.isFetching}
>
{__('Previous')}
</Button>
<span className="px-4 py-2 text-sm">
{__('Page')} {page} {__('of')} {pagination.total_pages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(pagination.total_pages, p + 1))}
disabled={page === pagination.total_pages || customersQuery.isFetching}
>
{__('Next')}
</Button>
</div>
)}
</div>
);
}