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:
@@ -22,6 +22,8 @@ import CouponsIndex from '@/routes/Coupons';
|
|||||||
import CouponNew from '@/routes/Coupons/New';
|
import CouponNew from '@/routes/Coupons/New';
|
||||||
import CouponEdit from '@/routes/Coupons/Edit';
|
import CouponEdit from '@/routes/Coupons/Edit';
|
||||||
import CustomersIndex from '@/routes/Customers';
|
import CustomersIndex from '@/routes/Customers';
|
||||||
|
import CustomerNew from '@/routes/Customers/New';
|
||||||
|
import CustomerEdit from '@/routes/Customers/Edit';
|
||||||
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';
|
||||||
@@ -482,6 +484,8 @@ function AppRoutes() {
|
|||||||
|
|
||||||
{/* Customers */}
|
{/* Customers */}
|
||||||
<Route path="/customers" element={<CustomersIndex />} />
|
<Route path="/customers" element={<CustomersIndex />} />
|
||||||
|
<Route path="/customers/new" element={<CustomerNew />} />
|
||||||
|
<Route path="/customers/:id/edit" element={<CustomerEdit />} />
|
||||||
|
|
||||||
{/* More */}
|
{/* More */}
|
||||||
<Route path="/more" element={<MorePage />} />
|
<Route path="/more" element={<MorePage />} />
|
||||||
|
|||||||
429
admin-spa/src/routes/Customers/CustomerForm.tsx
Normal file
429
admin-spa/src/routes/Customers/CustomerForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
admin-spa/src/routes/Customers/Edit.tsx
Normal file
104
admin-spa/src/routes/Customers/Edit.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
admin-spa/src/routes/Customers/New.tsx
Normal file
68
admin-spa/src/routes/Customers/New.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { __ } 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() {
|
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 (
|
return (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-semibold mb-3">{__('Customers')}</h1>
|
{/* Toolbar */}
|
||||||
<p className="opacity-70">{__('Coming soon — SPA customer list.')}</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user