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 { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { import {
Box, Box,
Typography, Typography,
@ -28,33 +29,23 @@ import SearchIcon from '@mui/icons-material/Search';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
import { toast } from 'react-toastify';
import { import {
customersApi, useCustomerList,
type Customer, useCreateCustomer,
type PagedCustomerResponse, useUpdateCustomer,
type CreateCustomerRequest, } from '../../../domain/customers/use-cases';
type UpdateCustomerRequest, import type { Customer } from '../../../domain/customers/types';
} from '../../../lib/api/customers'; import {
import { queryClient } from '../../../lib/queryClient'; customerFormSchema,
import { QUERY_KEYS } from '../../../constants/queryKeys'; toCustomerRequest,
type CustomerFormValues,
} from '../../../domain/customers/schemas';
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
const COL_COUNT = 4; // Name, Address, Contact Email, Actions const COL_COUNT = 4; // Name, Address, Contact Email, Actions
/** Simple email format check (client-side only). */ const EMPTY_FORM: CustomerFormValues = { name: '', address: '', contactEmail: '' };
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: '' };
export default function CustomerManagementPage() { export default function CustomerManagementPage() {
useDocumentTitle('Customer Management'); useDocumentTitle('Customer Management');
@ -78,14 +69,10 @@ export default function CustomerManagementPage() {
setDebounceTimer(timer); setDebounceTimer(timer);
}; };
const { const { data, isLoading, isError, refetch } = useCustomerList({
data, search: debouncedSearch || undefined,
isLoading, page,
isError, pageSize,
refetch,
} = useQuery<PagedCustomerResponse>({
queryKey: [QUERY_KEYS.customerList, debouncedSearch, page, pageSize],
queryFn: () => customersApi.list({ search: debouncedSearch || undefined, page, pageSize }),
}); });
const rows = data?.items ?? []; const rows = data?.items ?? [];
@ -94,101 +81,59 @@ export default function CustomerManagementPage() {
// --- Dialog state --- // --- Dialog state ---
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null); 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 isEditing = editingCustomer !== null;
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CustomerFormValues>({
resolver: zodResolver(customerFormSchema),
defaultValues: EMPTY_FORM,
});
const openCreateDialog = () => { const openCreateDialog = () => {
setEditingCustomer(null); setEditingCustomer(null);
setForm(EMPTY_FORM); reset(EMPTY_FORM);
setFormErrors({});
setDialogOpen(true); setDialogOpen(true);
}; };
const openEditDialog = (customer: Customer) => { const openEditDialog = (customer: Customer) => {
setEditingCustomer(customer); setEditingCustomer(customer);
setForm({ reset({
name: customer.name, name: customer.name,
address: customer.addresses?.[0] ?? '', address: customer.addresses?.[0] ?? '',
contactEmail: customer.contactEmail ?? '', contactEmail: customer.contactEmail ?? '',
}); });
setFormErrors({});
setDialogOpen(true); setDialogOpen(true);
}; };
const closeDialog = () => { const closeDialog = () => {
setDialogOpen(false); setDialogOpen(false);
setEditingCustomer(null); setEditingCustomer(null);
setForm(EMPTY_FORM); reset(EMPTY_FORM);
setFormErrors({});
}; };
const validateForm = (): boolean => { // --- Mutations (domain hooks: invalidation + toasts live in the hooks) ---
const errors: Partial<Record<keyof CustomerFormState, string>> = {}; const createMutation = useCreateCustomer();
if (!form.name.trim()) errors.name = 'Name is required'; const updateMutation = useUpdateCustomer();
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;
const onSubmit = (values: CustomerFormValues) => {
// Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs); // Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs);
// the old singular `address` field was silently dropped by model binding. // the old singular `address` field was silently dropped by model binding.
// The form edits the primary address; any additional addresses are preserved. // The form edits the primary address; any additional addresses are preserved
const payload = { // (toCustomerRequest maps to addresses: [primary, ...rest]).
name: form.name.trim(), const payload = toCustomerRequest(values, editingCustomer?.addresses ?? []);
addresses: [form.address.trim(), ...(editingCustomer?.addresses.slice(1) ?? [])],
...(form.contactEmail.trim() ? { contactEmail: form.contactEmail.trim() } : {}),
};
if (isEditing) { if (isEditing) {
updateMutation.mutate({ id: editingCustomer.id, data: payload }); updateMutation.mutate(
{ id: editingCustomer.id, data: payload },
{ onSuccess: closeDialog }
);
} else { } else {
createMutation.mutate(payload); createMutation.mutate(payload, { onSuccess: closeDialog });
} }
}; };
@ -324,54 +269,53 @@ export default function CustomerManagementPage() {
{/* Create / Edit Dialog */} {/* Create / Edit Dialog */}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth> <Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>{isEditing ? 'Edit Customer' : 'New Customer'}</DialogTitle> <form onSubmit={handleSubmit(onSubmit)} noValidate>
<DialogContent> <DialogTitle>{isEditing ? 'Edit Customer' : 'New Customer'}</DialogTitle>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}> <DialogContent>
<TextField <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
label="Name" <TextField
value={form.name} label="Name"
onChange={(e) => handleFieldChange('name', e.target.value)} {...register('name')}
error={!!formErrors.name} error={!!errors.name}
helperText={formErrors.name} helperText={errors.name?.message}
size="small" size="small"
required required
fullWidth fullWidth
autoFocus autoFocus
/> />
<TextField <TextField
label="Address" label="Address"
value={form.address} {...register('address')}
onChange={(e) => handleFieldChange('address', e.target.value)} error={!!errors.address}
error={!!formErrors.address} helperText={errors.address?.message}
helperText={formErrors.address} size="small"
size="small" required
required fullWidth
fullWidth />
/> <TextField
<TextField label="Contact Email"
label="Contact Email" type="email"
type="email" {...register('contactEmail')}
value={form.contactEmail} error={!!errors.contactEmail}
onChange={(e) => handleFieldChange('contactEmail', e.target.value)} helperText={errors.contactEmail?.message || 'Optional. Used for proposal PDF delivery.'}
error={!!formErrors.contactEmail} size="small"
helperText={formErrors.contactEmail || 'Optional. Used for proposal PDF delivery.'} fullWidth
size="small" />
fullWidth </Box>
/> </DialogContent>
</Box> <DialogActions>
</DialogContent> <Button onClick={closeDialog} disabled={isSaving}>
<DialogActions> Cancel
<Button onClick={closeDialog} disabled={isSaving}> </Button>
Cancel <Button
</Button> type="submit"
<Button variant="contained"
variant="contained" disabled={isSaving}
onClick={handleSubmit} >
disabled={isSaving} {isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Customer'}
> </Button>
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Customer'} </DialogActions>
</Button> </form>
</DialogActions>
</Dialog> </Dialog>
</Box> </Box>
); );