mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 10:28:55 +00:00
refactor(web): pricing library on domain layer + react-hook-form
This commit is contained in:
parent
e0261f0cee
commit
b7c6386aca
1 changed files with 162 additions and 202 deletions
|
|
@ -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 */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue