refactor(web): customer management on domain layer + react-hook-form

This commit is contained in:
Adam Moussa 2026-07-13 18:03:12 -04:00
parent f1087aa82d
commit e0261f0cee
No known key found for this signature in database

View file

@ -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<PagedCustomerResponse>({
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<Customer | null>(null);
const [form, setForm] = useState<CustomerFormState>(EMPTY_FORM);
const [formErrors, setFormErrors] = useState<Partial<Record<keyof CustomerFormState, string>>>({});
const isEditing = editingCustomer !== null;
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CustomerFormValues>({
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<Record<keyof CustomerFormState, string>> = {};
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 */}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>{isEditing ? 'Edit Customer' : 'New Customer'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Name"
value={form.name}
onChange={(e) => handleFieldChange('name', e.target.value)}
error={!!formErrors.name}
helperText={formErrors.name}
size="small"
required
fullWidth
autoFocus
/>
<TextField
label="Address"
value={form.address}
onChange={(e) => handleFieldChange('address', e.target.value)}
error={!!formErrors.address}
helperText={formErrors.address}
size="small"
required
fullWidth
/>
<TextField
label="Contact Email"
type="email"
value={form.contactEmail}
onChange={(e) => handleFieldChange('contactEmail', e.target.value)}
error={!!formErrors.contactEmail}
helperText={formErrors.contactEmail || 'Optional. Used for proposal PDF delivery.'}
size="small"
fullWidth
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog} disabled={isSaving}>
Cancel
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={isSaving}
>
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Customer'}
</Button>
</DialogActions>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<DialogTitle>{isEditing ? 'Edit Customer' : 'New Customer'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Name"
{...register('name')}
error={!!errors.name}
helperText={errors.name?.message}
size="small"
required
fullWidth
autoFocus
/>
<TextField
label="Address"
{...register('address')}
error={!!errors.address}
helperText={errors.address?.message}
size="small"
required
fullWidth
/>
<TextField
label="Contact Email"
type="email"
{...register('contactEmail')}
error={!!errors.contactEmail}
helperText={errors.contactEmail?.message || 'Optional. Used for proposal PDF delivery.'}
size="small"
fullWidth
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog} disabled={isSaving}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
disabled={isSaving}
>
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Customer'}
</Button>
</DialogActions>
</form>
</Dialog>
</Box>
);