diff --git a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx index ea200c6..13581d6 100644 --- a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx +++ b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx @@ -1,5 +1,6 @@ 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 { Box, Typography, @@ -29,17 +30,18 @@ import AddIcon from '@mui/icons-material/Add'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; -import { toast } from 'react-toastify'; import { - pricingLibraryApi, - type PricingLibraryItem, - type PagedPricingLibraryResponse, - type CreatePricingLibraryItemRequest, - type UpdatePricingLibraryItemRequest, -} from '../../../lib/api/pricingLibrary'; -import type { ServiceCategory } from '@proposal-system/api-contracts'; -import { queryClient } from '../../../lib/queryClient'; -import { QUERY_KEYS } from '../../../constants/queryKeys'; + usePricingLibraryList, + useCreatePricingLibraryItem, + useUpdatePricingLibraryItem, + useDeletePricingLibraryItem, +} from '../../../domain/pricingLibrary/use-cases'; +import type { PricingLibraryItem } from '../../../domain/pricingLibrary/types'; +import { + pricingLibraryItemFormSchema, + toPricingLibraryItemRequest, + type PricingLibraryItemFormValues, +} from '../../../domain/pricingLibrary/schemas'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; @@ -55,18 +57,12 @@ const CATEGORY_CHIP_STYLES: Record = { title: '', - serviceCategory: '', unit: '', unitPrice: '', keywords: '', @@ -86,15 +82,7 @@ export default function PricingLibraryPage() { const [page, setPage] = useState(DEFAULT_PAGE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const { - data, - isLoading, - isError, - refetch, - } = useQuery({ - queryKey: [QUERY_KEYS.pricingLibraryList, page, pageSize], - queryFn: () => pricingLibraryApi.list({ page, pageSize }), - }); + const { data, isLoading, isError, refetch } = usePricingLibraryList({ page, pageSize }); const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; @@ -102,21 +90,28 @@ export default function PricingLibraryPage() { // --- Create/Edit dialog state --- const [dialogOpen, setDialogOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); - const [form, setForm] = useState(EMPTY_FORM); - const [formErrors, setFormErrors] = useState>>({}); const isEditing = editingItem !== null; + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(pricingLibraryItemFormSchema), + defaultValues: EMPTY_FORM_VALUES, + }); + const openCreateDialog = () => { setEditingItem(null); - setForm(EMPTY_FORM); - setFormErrors({}); + reset(EMPTY_FORM_VALUES); setDialogOpen(true); }; const openEditDialog = (item: PricingLibraryItem) => { setEditingItem(item); - setForm({ + reset({ title: item.title, serviceCategory: item.serviceCategory, unit: item.unit ?? '', @@ -124,40 +119,13 @@ export default function PricingLibraryPage() { keywords: item.keywords ?? '', description: item.description ?? '', }); - setFormErrors({}); setDialogOpen(true); }; const closeDialog = () => { setDialogOpen(false); setEditingItem(null); - setForm(EMPTY_FORM); - setFormErrors({}); - }; - - const validateForm = (): boolean => { - const errors: Partial> = {}; - 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; - }); - } + reset(EMPTY_FORM_VALUES); }; // --- Delete confirm dialog state --- @@ -171,67 +139,26 @@ export default function PricingLibraryPage() { setDeleteTarget(null); }; - // --- Mutations --- - const createMutation = useMutation({ - mutationFn: (data: CreatePricingLibraryItemRequest) => pricingLibraryApi.create(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); - closeDialog(); - toast.success('Pricing item created'); - }, - onError: (error: Error) => { - toast.error(`Failed to create item: ${error.message}`); - }, - }); + // --- Mutations (domain hooks own invalidation + toasts; dialog close stays here) --- + const createMutation = useCreatePricingLibraryItem(); + const updateMutation = useUpdatePricingLibraryItem(); + const deleteMutation = useDeletePricingLibraryItem(); - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: UpdatePricingLibraryItemRequest }) => - pricingLibraryApi.update(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); - 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 }); + const onSubmit = handleSubmit((values) => { + const payload = toPricingLibraryItemRequest(values); + if (editingItem) { + updateMutation.mutate( + { id: editingItem.id, data: payload }, + { onSuccess: () => closeDialog() } + ); } else { - createMutation.mutate(payload); + createMutation.mutate(payload, { onSuccess: () => closeDialog() }); } - }; + }); const handleDeleteConfirm = () => { if (deleteTarget) { - deleteMutation.mutate(deleteTarget.id); + deleteMutation.mutate(deleteTarget.id, { onSuccess: () => closeDeleteDialog() }); } }; @@ -368,90 +295,123 @@ export default function PricingLibraryPage() { {/* Create / Edit Dialog */} {isEditing ? 'Edit Pricing Item' : 'New Pricing Item'} - - - handleFieldChange('title', e.target.value)} - error={!!formErrors.title} - helperText={formErrors.title} - size="small" - required - fullWidth - autoFocus - /> - handleFieldChange('serviceCategory', e.target.value)} - error={!!formErrors.serviceCategory} - helperText={formErrors.serviceCategory} - size="small" - required - fullWidth - select +
+ + + ( + + )} + /> + ( + + {SERVICE_CATEGORIES.map((cat) => ( + + {cat} + + ))} + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + + + + - - + {isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'} + + +
{/* Delete Confirm Dialog */}