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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user