diff --git a/web/src/pages/admin/customers/CustomerManagementPage.tsx b/web/src/pages/admin/customers/CustomerManagementPage.tsx index bd6a515..aa6b26a 100644 --- a/web/src/pages/admin/customers/CustomerManagementPage.tsx +++ b/web/src/pages/admin/customers/CustomerManagementPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { Box, Typography, @@ -28,33 +29,23 @@ import SearchIcon from '@mui/icons-material/Search'; import AddIcon from '@mui/icons-material/Add'; import EditIcon from '@mui/icons-material/Edit'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; -import { toast } from 'react-toastify'; import { - customersApi, - type Customer, - type PagedCustomerResponse, - type CreateCustomerRequest, - type UpdateCustomerRequest, -} from '../../../lib/api/customers'; -import { queryClient } from '../../../lib/queryClient'; -import { QUERY_KEYS } from '../../../constants/queryKeys'; + useCustomerList, + useCreateCustomer, + useUpdateCustomer, +} from '../../../domain/customers/use-cases'; +import type { Customer } from '../../../domain/customers/types'; +import { + customerFormSchema, + toCustomerRequest, + type CustomerFormValues, +} from '../../../domain/customers/schemas'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; const COL_COUNT = 4; // Name, Address, Contact Email, Actions -/** Simple email format check (client-side only). */ -function isValidEmail(email: string): boolean { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - -interface CustomerFormState { - name: string; - address: string; - contactEmail: string; -} - -const EMPTY_FORM: CustomerFormState = { name: '', address: '', contactEmail: '' }; +const EMPTY_FORM: CustomerFormValues = { name: '', address: '', contactEmail: '' }; export default function CustomerManagementPage() { useDocumentTitle('Customer Management'); @@ -78,14 +69,10 @@ export default function CustomerManagementPage() { setDebounceTimer(timer); }; - const { - data, - isLoading, - isError, - refetch, - } = useQuery({ - queryKey: [QUERY_KEYS.customerList, debouncedSearch, page, pageSize], - queryFn: () => customersApi.list({ search: debouncedSearch || undefined, page, pageSize }), + const { data, isLoading, isError, refetch } = useCustomerList({ + search: debouncedSearch || undefined, + page, + pageSize, }); const rows = data?.items ?? []; @@ -94,101 +81,59 @@ export default function CustomerManagementPage() { // --- Dialog state --- const [dialogOpen, setDialogOpen] = useState(false); const [editingCustomer, setEditingCustomer] = useState(null); - const [form, setForm] = useState(EMPTY_FORM); - const [formErrors, setFormErrors] = useState>>({}); const isEditing = editingCustomer !== null; + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(customerFormSchema), + defaultValues: EMPTY_FORM, + }); + const openCreateDialog = () => { setEditingCustomer(null); - setForm(EMPTY_FORM); - setFormErrors({}); + reset(EMPTY_FORM); setDialogOpen(true); }; const openEditDialog = (customer: Customer) => { setEditingCustomer(customer); - setForm({ + reset({ name: customer.name, address: customer.addresses?.[0] ?? '', contactEmail: customer.contactEmail ?? '', }); - setFormErrors({}); setDialogOpen(true); }; const closeDialog = () => { setDialogOpen(false); setEditingCustomer(null); - setForm(EMPTY_FORM); - setFormErrors({}); + reset(EMPTY_FORM); }; - const validateForm = (): boolean => { - const errors: Partial> = {}; - if (!form.name.trim()) errors.name = 'Name is required'; - if (!form.address.trim()) errors.address = 'Address is required'; - if (form.contactEmail.trim() && !isValidEmail(form.contactEmail.trim())) { - errors.contactEmail = 'Enter a valid email address'; - } - setFormErrors(errors); - return Object.keys(errors).length === 0; - }; - - const handleFieldChange = (field: keyof CustomerFormState, value: string) => { - setForm((prev) => ({ ...prev, [field]: value })); - // Clear field error on change - if (formErrors[field]) { - setFormErrors((prev) => { - const next = { ...prev }; - delete next[field]; - return next; - }); - } - }; - - // --- Mutations --- - const createMutation = useMutation({ - mutationFn: (data: CreateCustomerRequest) => customersApi.create(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.customerList] }); - closeDialog(); - toast.success('Customer created'); - }, - onError: (error: Error) => { - toast.error(`Failed to create customer: ${error.message}`); - }, - }); - - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) => - customersApi.update(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.customerList] }); - closeDialog(); - toast.success('Customer updated'); - }, - onError: (error: Error) => { - toast.error(`Failed to update customer: ${error.message}`); - }, - }); - - const handleSubmit = () => { - if (!validateForm()) return; + // --- Mutations (domain hooks: invalidation + toasts live in the hooks) --- + const createMutation = useCreateCustomer(); + const updateMutation = useUpdateCustomer(); + const onSubmit = (values: CustomerFormValues) => { // Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs); // the old singular `address` field was silently dropped by model binding. - // The form edits the primary address; any additional addresses are preserved. - const payload = { - name: form.name.trim(), - addresses: [form.address.trim(), ...(editingCustomer?.addresses.slice(1) ?? [])], - ...(form.contactEmail.trim() ? { contactEmail: form.contactEmail.trim() } : {}), - }; + // The form edits the primary address; any additional addresses are preserved + // (toCustomerRequest maps to addresses: [primary, ...rest]). + const payload = toCustomerRequest(values, editingCustomer?.addresses ?? []); if (isEditing) { - updateMutation.mutate({ id: editingCustomer.id, data: payload }); + updateMutation.mutate( + { id: editingCustomer.id, data: payload }, + { onSuccess: closeDialog } + ); } else { - createMutation.mutate(payload); + createMutation.mutate(payload, { onSuccess: closeDialog }); } }; @@ -324,54 +269,53 @@ export default function CustomerManagementPage() { {/* Create / Edit Dialog */} - {isEditing ? 'Edit Customer' : 'New Customer'} - - - handleFieldChange('name', e.target.value)} - error={!!formErrors.name} - helperText={formErrors.name} - size="small" - required - fullWidth - autoFocus - /> - handleFieldChange('address', e.target.value)} - error={!!formErrors.address} - helperText={formErrors.address} - size="small" - required - fullWidth - /> - handleFieldChange('contactEmail', e.target.value)} - error={!!formErrors.contactEmail} - helperText={formErrors.contactEmail || 'Optional. Used for proposal PDF delivery.'} - size="small" - fullWidth - /> - - - - - - +
+ {isEditing ? 'Edit Customer' : 'New Customer'} + + + + + + + + + + + +
);