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

@@ -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>
);
}