refactor(web): pricing library on domain layer + react-hook-form

This commit is contained in:
Adam Moussa 2026-07-13 18:05:47 -04:00
parent e0261f0cee
commit b7c6386aca
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 { Controller, useForm, type DefaultValues } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { import {
Box, Box,
Typography, Typography,
@ -29,17 +30,18 @@ import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
import { toast } from 'react-toastify';
import { import {
pricingLibraryApi, usePricingLibraryList,
type PricingLibraryItem, useCreatePricingLibraryItem,
type PagedPricingLibraryResponse, useUpdatePricingLibraryItem,
type CreatePricingLibraryItemRequest, useDeletePricingLibraryItem,
type UpdatePricingLibraryItemRequest, } from '../../../domain/pricingLibrary/use-cases';
} from '../../../lib/api/pricingLibrary'; import type { PricingLibraryItem } from '../../../domain/pricingLibrary/types';
import type { ServiceCategory } from '@proposal-system/api-contracts'; import {
import { queryClient } from '../../../lib/queryClient'; pricingLibraryItemFormSchema,
import { QUERY_KEYS } from '../../../constants/queryKeys'; toPricingLibraryItemRequest,
type PricingLibraryItemFormValues,
} from '../../../domain/pricingLibrary/schemas';
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
@ -55,18 +57,12 @@ const CATEGORY_CHIP_STYLES: Record<string, { bgcolor: string; color: string; bor
Other: { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' }, Other: { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' },
}; };
interface ItemFormState { /**
title: string; * serviceCategory stays union-typed (ServiceCategory) in the form schema, so the
serviceCategory: ServiceCategory | ''; * create defaults leave it unset rather than using an out-of-union '' sentinel.
unit: string; */
unitPrice: string; const EMPTY_FORM_VALUES: DefaultValues<PricingLibraryItemFormValues> = {
keywords: string;
description: string;
}
const EMPTY_FORM: ItemFormState = {
title: '', title: '',
serviceCategory: '',
unit: '', unit: '',
unitPrice: '', unitPrice: '',
keywords: '', keywords: '',
@ -86,15 +82,7 @@ export default function PricingLibraryPage() {
const [page, setPage] = useState(DEFAULT_PAGE); const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const { const { data, isLoading, isError, refetch } = usePricingLibraryList({ page, pageSize });
data,
isLoading,
isError,
refetch,
} = useQuery<PagedPricingLibraryResponse>({
queryKey: [QUERY_KEYS.pricingLibraryList, page, pageSize],
queryFn: () => pricingLibraryApi.list({ page, pageSize }),
});
const rows = data?.items ?? []; const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0; const totalCount = data?.totalCount ?? 0;
@ -102,21 +90,28 @@ export default function PricingLibraryPage() {
// --- Create/Edit dialog state --- // --- Create/Edit dialog state ---
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<PricingLibraryItem | null>(null); const [editingItem, setEditingItem] = useState<PricingLibraryItem | null>(null);
const [form, setForm] = useState<ItemFormState>(EMPTY_FORM);
const [formErrors, setFormErrors] = useState<Partial<Record<keyof ItemFormState, string>>>({});
const isEditing = editingItem !== null; const isEditing = editingItem !== null;
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<PricingLibraryItemFormValues>({
resolver: zodResolver(pricingLibraryItemFormSchema),
defaultValues: EMPTY_FORM_VALUES,
});
const openCreateDialog = () => { const openCreateDialog = () => {
setEditingItem(null); setEditingItem(null);
setForm(EMPTY_FORM); reset(EMPTY_FORM_VALUES);
setFormErrors({});
setDialogOpen(true); setDialogOpen(true);
}; };
const openEditDialog = (item: PricingLibraryItem) => { const openEditDialog = (item: PricingLibraryItem) => {
setEditingItem(item); setEditingItem(item);
setForm({ reset({
title: item.title, title: item.title,
serviceCategory: item.serviceCategory, serviceCategory: item.serviceCategory,
unit: item.unit ?? '', unit: item.unit ?? '',
@ -124,40 +119,13 @@ export default function PricingLibraryPage() {
keywords: item.keywords ?? '', keywords: item.keywords ?? '',
description: item.description ?? '', description: item.description ?? '',
}); });
setFormErrors({});
setDialogOpen(true); setDialogOpen(true);
}; };
const closeDialog = () => { const closeDialog = () => {
setDialogOpen(false); setDialogOpen(false);
setEditingItem(null); setEditingItem(null);
setForm(EMPTY_FORM); reset(EMPTY_FORM_VALUES);
setFormErrors({});
};
const validateForm = (): boolean => {
const errors: Partial<Record<keyof ItemFormState, string>> = {};
if (!form.title.trim()) errors.title = 'Title is required';
if (!form.serviceCategory) errors.serviceCategory = 'Service category is required';
if (form.unitPrice.trim() !== '') {
const parsed = parseFloat(form.unitPrice);
if (isNaN(parsed) || parsed < 0) {
errors.unitPrice = 'Must be a number >= 0';
}
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleFieldChange = (field: keyof ItemFormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
if (formErrors[field]) {
setFormErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
}; };
// --- Delete confirm dialog state --- // --- Delete confirm dialog state ---
@ -171,67 +139,26 @@ export default function PricingLibraryPage() {
setDeleteTarget(null); setDeleteTarget(null);
}; };
// --- Mutations --- // --- Mutations (domain hooks own invalidation + toasts; dialog close stays here) ---
const createMutation = useMutation({ const createMutation = useCreatePricingLibraryItem();
mutationFn: (data: CreatePricingLibraryItemRequest) => pricingLibraryApi.create(data), const updateMutation = useUpdatePricingLibraryItem();
onSuccess: () => { const deleteMutation = useDeletePricingLibraryItem();
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] });
closeDialog();
toast.success('Pricing item created');
},
onError: (error: Error) => {
toast.error(`Failed to create item: ${error.message}`);
},
});
const updateMutation = useMutation({ const onSubmit = handleSubmit((values) => {
mutationFn: ({ id, data }: { id: string; data: UpdatePricingLibraryItemRequest }) => const payload = toPricingLibraryItemRequest(values);
pricingLibraryApi.update(id, data), if (editingItem) {
onSuccess: () => { updateMutation.mutate(
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); { id: editingItem.id, data: payload },
closeDialog(); { onSuccess: () => closeDialog() }
toast.success('Pricing item updated'); );
},
onError: (error: Error) => {
toast.error(`Failed to update item: ${error.message}`);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => pricingLibraryApi.remove(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] });
closeDeleteDialog();
toast.success('Pricing item deleted');
},
onError: (error: Error) => {
toast.error(`Failed to delete item: ${error.message}`);
},
});
const handleSubmit = () => {
if (!validateForm()) return;
if (!form.serviceCategory) return; // validateForm guarantees this; narrows '' out of the union
const payload = {
title: form.title.trim(),
serviceCategory: form.serviceCategory,
...(form.unit.trim() ? { unit: form.unit.trim() } : {}),
unitPrice: form.unitPrice.trim() !== '' ? parseFloat(form.unitPrice) : null,
...(form.keywords.trim() ? { keywords: form.keywords.trim() } : {}),
...(form.description.trim() ? { description: form.description.trim() } : {}),
};
if (isEditing) {
updateMutation.mutate({ id: editingItem.id, data: payload });
} else { } else {
createMutation.mutate(payload); createMutation.mutate(payload, { onSuccess: () => closeDialog() });
} }
}; });
const handleDeleteConfirm = () => { const handleDeleteConfirm = () => {
if (deleteTarget) { if (deleteTarget) {
deleteMutation.mutate(deleteTarget.id); deleteMutation.mutate(deleteTarget.id, { onSuccess: () => closeDeleteDialog() });
} }
}; };
@ -368,25 +295,35 @@ export default function PricingLibraryPage() {
{/* 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 Pricing Item' : 'New Pricing Item'}</DialogTitle> <DialogTitle>{isEditing ? 'Edit Pricing Item' : 'New Pricing Item'}</DialogTitle>
<form onSubmit={onSubmit} noValidate>
<DialogContent> <DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<Controller
name="title"
control={control}
render={({ field }) => (
<TextField <TextField
{...field}
label="Title" label="Title"
value={form.title} error={!!errors.title}
onChange={(e) => handleFieldChange('title', e.target.value)} helperText={errors.title?.message}
error={!!formErrors.title}
helperText={formErrors.title}
size="small" size="small"
required required
fullWidth fullWidth
autoFocus autoFocus
/> />
)}
/>
<Controller
name="serviceCategory"
control={control}
render={({ field }) => (
<TextField <TextField
{...field}
value={field.value ?? ''}
label="Service Category" label="Service Category"
value={form.serviceCategory} error={!!errors.serviceCategory}
onChange={(e) => handleFieldChange('serviceCategory', e.target.value)} helperText={errors.serviceCategory ? 'Service category is required' : undefined}
error={!!formErrors.serviceCategory}
helperText={formErrors.serviceCategory}
size="small" size="small"
required required
fullWidth fullWidth
@ -398,46 +335,68 @@ export default function PricingLibraryPage() {
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
)}
/>
<Controller
name="unit"
control={control}
render={({ field }) => (
<TextField <TextField
{...field}
label="Unit" label="Unit"
value={form.unit}
onChange={(e) => handleFieldChange('unit', e.target.value)}
helperText="e.g., each, hour, sq ft" helperText="e.g., each, hour, sq ft"
size="small" size="small"
fullWidth fullWidth
/> />
)}
/>
<Controller
name="unitPrice"
control={control}
render={({ field }) => (
<TextField <TextField
{...field}
label="Unit Price" label="Unit Price"
type="number" type="number"
value={form.unitPrice} error={!!errors.unitPrice}
onChange={(e) => handleFieldChange('unitPrice', e.target.value)} helperText={errors.unitPrice?.message || 'Optional. Leave blank if price varies.'}
error={!!formErrors.unitPrice}
helperText={formErrors.unitPrice || 'Optional. Leave blank if price varies.'}
size="small" size="small"
fullWidth fullWidth
slotProps={{ slotProps={{
htmlInput: { min: 0, step: '0.01' }, htmlInput: { min: 0, step: '0.01' },
}} }}
/> />
)}
/>
<Controller
name="keywords"
control={control}
render={({ field }) => (
<TextField <TextField
{...field}
label="Keywords" label="Keywords"
value={form.keywords}
onChange={(e) => handleFieldChange('keywords', e.target.value)}
helperText="Comma-separated keywords for AI matching." helperText="Comma-separated keywords for AI matching."
size="small" size="small"
fullWidth fullWidth
multiline multiline
minRows={2} minRows={2}
/> />
)}
/>
<Controller
name="description"
control={control}
render={({ field }) => (
<TextField <TextField
{...field}
label="Description" label="Description"
value={form.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
size="small" size="small"
fullWidth fullWidth
multiline multiline
minRows={2} minRows={2}
/> />
)}
/>
</Box> </Box>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
@ -445,13 +404,14 @@ export default function PricingLibraryPage() {
Cancel Cancel
</Button> </Button>
<Button <Button
type="submit"
variant="contained" variant="contained"
onClick={handleSubmit}
disabled={isSaving} disabled={isSaving}
> >
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'} {isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'}
</Button> </Button>
</DialogActions> </DialogActions>
</form>
</Dialog> </Dialog>
{/* Delete Confirm Dialog */} {/* Delete Confirm Dialog */}