diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index 03cbe13..efac767 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -22,6 +22,8 @@ import CouponsIndex from '@/routes/Coupons'; import CouponNew from '@/routes/Coupons/New'; import CouponEdit from '@/routes/Coupons/Edit'; 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 { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Maximize2, Minimize2, Loader2 } from 'lucide-react'; import { Toaster } from 'sonner'; @@ -482,6 +484,8 @@ function AppRoutes() { {/* Customers */} } /> + } /> + } /> {/* More */} } /> diff --git a/admin-spa/src/routes/Customers/CustomerForm.tsx b/admin-spa/src/routes/Customers/CustomerForm.tsx new file mode 100644 index 0000000..27dff54 --- /dev/null +++ b/admin-spa/src/routes/Customers/CustomerForm.tsx @@ -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; + className?: string; + formRef?: React.RefObject; + 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: }, + { id: 'billing', label: __('Billing Address'), icon: }, + { id: 'shipping', label: __('Shipping Address'), icon: }, + ]; + + return ( +
+ + {/* Personal Data */} + + + + {__('Personal Information')} + + {__('Basic customer information and account details')} + + + +
+
+ + setFirstName(e.target.value)} + required + /> +
+ +
+ + setLastName(e.target.value)} + required + /> +
+
+ +
+ + setEmail(e.target.value)} + required + /> +
+ + {mode === 'create' && ( +
+ + setUsername(e.target.value)} + placeholder={__('Leave empty to use email')} + /> +

+ {__('Username will be generated from email if left empty')} +

+
+ )} + +
+ + setPassword(e.target.value)} + placeholder={mode === 'create' ? __('Leave empty to auto-generate') : __('Leave empty to keep current')} + /> + {mode === 'create' && ( +

+ {__('A secure password will be generated if left empty')} +

+ )} +
+ + {mode === 'create' && ( +
+ setSendEmail(Boolean(checked))} + /> + +
+ )} +
+
+
+ + {/* Billing Address */} + + + + {__('Billing Address')} + + {__('Customer billing information')} + + + +
+ + setBillingCompany(e.target.value)} + /> +
+ +
+ + setBillingAddress1(e.target.value)} + /> +
+ +
+ + setBillingAddress2(e.target.value)} + /> +
+ +
+
+ + setBillingCity(e.target.value)} + /> +
+ +
+ + setBillingState(e.target.value)} + /> +
+
+ +
+
+ + setBillingPostcode(e.target.value)} + /> +
+ +
+ + setBillingCountry(e.target.value)} + placeholder="ID" + /> +
+
+ +
+ + setBillingPhone(e.target.value)} + /> +
+
+
+
+ + {/* Shipping Address */} + + + + {__('Shipping Address')} + + {__('Customer shipping information')} + + + +
+ setCopyBilling(Boolean(checked))} + /> + +
+ +
+ + setShippingCompany(e.target.value)} + disabled={copyBilling} + /> +
+ +
+ + setShippingAddress1(e.target.value)} + disabled={copyBilling} + /> +
+ +
+ + setShippingAddress2(e.target.value)} + disabled={copyBilling} + /> +
+ +
+
+ + setShippingCity(e.target.value)} + disabled={copyBilling} + /> +
+ +
+ + setShippingState(e.target.value)} + disabled={copyBilling} + /> +
+
+ +
+
+ + setShippingPostcode(e.target.value)} + disabled={copyBilling} + /> +
+ +
+ + setShippingCountry(e.target.value)} + placeholder="ID" + disabled={copyBilling} + /> +
+
+
+
+
+
+ + {!hideSubmitButton && ( +
+ +
+ )} +
+ ); +} diff --git a/admin-spa/src/routes/Customers/Edit.tsx b/admin-spa/src/routes/Customers/Edit.tsx new file mode 100644 index 0000000..2955820 --- /dev/null +++ b/admin-spa/src/routes/Customers/Edit.tsx @@ -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(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 = ( +
+ + +
+ ); + setPageHeader(__('Edit Customer'), actions); + return () => clearPageHeader(); + }, [updateMutation.isPending, customerQuery.isLoading, setPageHeader, clearPageHeader, navigate]); + + // Loading state + if (customerQuery.isLoading) { + return ( +
+ + + +
+ ); + } + + // Error state + if (customerQuery.isError) { + return ( + customerQuery.refetch()} + /> + ); + } + + const customer = customerQuery.data; + + return ( +
+ +
+ ); +} diff --git a/admin-spa/src/routes/Customers/New.tsx b/admin-spa/src/routes/Customers/New.tsx new file mode 100644 index 0000000..b31ae57 --- /dev/null +++ b/admin-spa/src/routes/Customers/New.tsx @@ -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(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 = ( +
+ + +
+ ); + setPageHeader(__('New Customer'), actions); + return () => clearPageHeader(); + }, [createMutation.isPending, setPageHeader, clearPageHeader, navigate]); + + return ( +
+ +
+ ); +} diff --git a/admin-spa/src/routes/Customers/index.tsx b/admin-spa/src/routes/Customers/index.tsx index 2889d1d..d1b5b7d 100644 --- a/admin-spa/src/routes/Customers/index.tsx +++ b/admin-spa/src/routes/Customers/index.tsx @@ -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([]); + + // 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 ( +
+ + +
+ ); + } + + // Error state + if (customersQuery.isError) { + return ( + customersQuery.refetch()} + /> + ); + } + return ( -
-

{__('Customers')}

-

{__('Coming soon — SPA customer list.')}

+
+ {/* Toolbar */} +
+
+ {/* Left: Bulk Actions */} +
+ {selectedIds.length > 0 && ( + + )} + + +
+ + {/* Right: Search */} +
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + className="pl-9 w-64" + /> +
+
+
+
+ + {/* Desktop: Table */} +
+ + + + + + + + + + + + + {customers.length === 0 ? ( + + + + ) : ( + customers.map((customer) => ( + + + + + + + + + )) + )} + +
+ 0} + onCheckedChange={toggleAll} + aria-label={__('Select all')} + /> + {__('Customer')}{__('Email')}{__('Orders')}{__('Total Spent')}{__('Registered')}
+ + {search ? __('No customers found matching your search') : __('No customers yet')} + {!search && ( +

+ + {__('Create your first customer')} + +

+ )} +
+ toggleSelection(customer.id)} + aria-label={__('Select customer')} + /> + + + {customer.display_name || `${customer.first_name} ${customer.last_name}`} + + {customer.email}{customer.stats?.total_orders || 0} + {customer.stats?.total_spent ? formatMoney(customer.stats.total_spent) : '—'} + + {new Date(customer.registered).toLocaleDateString()} +
+
+ + {/* Mobile: Cards */} +
+ {customers.length === 0 ? ( + + + {search ? __('No customers found') : __('No customers yet')} + + ) : ( + customers.map((customer) => ( + +
+ toggleSelection(customer.id)} + /> +
+ + {customer.display_name || `${customer.first_name} ${customer.last_name}`} + +

{customer.email}

+
+ {customer.stats?.total_orders || 0} {__('orders')} + + {customer.stats?.total_spent ? formatMoney(customer.stats.total_spent) : '—'} + +
+
+
+
+ )) + )} +
+ + {/* Pagination */} + {pagination && pagination.total_pages > 1 && ( +
+ + + {__('Page')} {page} {__('of')} {pagination.total_pages} + + +
+ )}
); }