mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 05:23:14 +00:00
Bump @mui/material and @mui/icons-material 7.3.1 -> ^9.1.1 together in /web. They must move in lockstep (icons peer-depends on material), so Dependabot's separate per-package PRs (#145/#146) could never go green individually (ERESOLVE). Adds a 'mui' Dependabot group so future @mui/* updates — including majors — are raised as one PR. MUI v9 migration fixes (v7->v9 spans two majors): - ListItemText: primaryTypographyProps -> slotProps.primary; fontSize moved into sx (Typography system props removed in v9). - Autocomplete renderInput: params.InputProps -> params.slotProps.input; spread ...params.slotProps to retain inputLabel/htmlInput slots. - Icons: @mui/icons-material/ErrorOutline -> ErrorOutlined (the plain ErrorOutline export was removed in v9). - vitest: inline @mui/material + react-transition-group so Vite resolves v9's Transition.mjs directory import (native ESM loader can't). Verified locally on Node 24: npm ci, npm run build, and npm test (26 tests, 3 suites) all pass.
479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Card,
|
|
CardContent,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
TablePagination,
|
|
TextField,
|
|
Button,
|
|
Skeleton,
|
|
IconButton,
|
|
Tooltip,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
Alert,
|
|
Chip,
|
|
MenuItem,
|
|
} from '@mui/material';
|
|
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 { queryClient } from '../../../lib/queryClient';
|
|
import { QUERY_KEYS } from '../../../constants/queryKeys';
|
|
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants';
|
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
|
|
|
const COL_COUNT = 6; // Title, Category, Unit, Unit Price, Source, Actions
|
|
|
|
/** Chip color mapping for service categories. */
|
|
const CATEGORY_CHIP_STYLES: Record<string, { bgcolor: string; color: string; border: string }> = {
|
|
HVAC: { bgcolor: '#E0F2FE', color: '#075985', border: '1px solid #7DD3FC' },
|
|
Plumbing: { bgcolor: '#DBEAFE', color: '#1E40AF', border: '1px solid #BFDBFE' },
|
|
Electrical: { bgcolor: '#FEF9C3', color: '#854D0E', border: '1px solid #FDE68A' },
|
|
General: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' },
|
|
Renovation: { bgcolor: '#F3E8FF', color: '#6B21A8', border: '1px solid #C084FC' },
|
|
Other: { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' },
|
|
};
|
|
|
|
interface ItemFormState {
|
|
title: string;
|
|
serviceCategory: string;
|
|
unit: string;
|
|
unitPrice: string;
|
|
keywords: string;
|
|
description: string;
|
|
}
|
|
|
|
const EMPTY_FORM: ItemFormState = {
|
|
title: '',
|
|
serviceCategory: '',
|
|
unit: '',
|
|
unitPrice: '',
|
|
keywords: '',
|
|
description: '',
|
|
};
|
|
|
|
/** Format a number as $X.XX or return "—" for null/undefined. */
|
|
function formatPrice(value: number | null | undefined): string {
|
|
if (value == null) return '—';
|
|
return `$${value.toFixed(2)}`;
|
|
}
|
|
|
|
export default function PricingLibraryPage() {
|
|
useDocumentTitle('Pricing Library');
|
|
|
|
// --- List state ---
|
|
const [page, setPage] = useState(DEFAULT_PAGE);
|
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
|
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isError,
|
|
refetch,
|
|
} = useQuery<PagedPricingLibraryResponse>({
|
|
queryKey: [QUERY_KEYS.pricingLibraryList, page, pageSize],
|
|
queryFn: () => pricingLibraryApi.list({ page, pageSize }),
|
|
});
|
|
|
|
const rows = data?.items ?? [];
|
|
const totalCount = data?.totalCount ?? 0;
|
|
|
|
// --- Create/Edit dialog state ---
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
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 openCreateDialog = () => {
|
|
setEditingItem(null);
|
|
setForm(EMPTY_FORM);
|
|
setFormErrors({});
|
|
setDialogOpen(true);
|
|
};
|
|
|
|
const openEditDialog = (item: PricingLibraryItem) => {
|
|
setEditingItem(item);
|
|
setForm({
|
|
title: item.title,
|
|
serviceCategory: item.serviceCategory,
|
|
unit: item.unit ?? '',
|
|
unitPrice: item.unitPrice != null ? String(item.unitPrice) : '',
|
|
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<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 ---
|
|
const [deleteTarget, setDeleteTarget] = useState<PricingLibraryItem | null>(null);
|
|
|
|
const openDeleteDialog = (item: PricingLibraryItem) => {
|
|
setDeleteTarget(item);
|
|
};
|
|
|
|
const closeDeleteDialog = () => {
|
|
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}`);
|
|
},
|
|
});
|
|
|
|
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;
|
|
|
|
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 {
|
|
createMutation.mutate(payload);
|
|
}
|
|
};
|
|
|
|
const handleDeleteConfirm = () => {
|
|
if (deleteTarget) {
|
|
deleteMutation.mutate(deleteTarget.id);
|
|
}
|
|
};
|
|
|
|
const isSaving = createMutation.isPending || updateMutation.isPending;
|
|
|
|
return (
|
|
<Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
|
<Typography variant="h5">Pricing Library</Typography>
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<AddIcon />}
|
|
onClick={openCreateDialog}
|
|
>
|
|
New Item
|
|
</Button>
|
|
</Box>
|
|
|
|
<Card>
|
|
<CardContent sx={{ pb: 0 }}>
|
|
{/* Error state */}
|
|
{isError && (
|
|
<Alert
|
|
severity="error"
|
|
icon={<ErrorOutlineIcon />}
|
|
action={
|
|
<Button color="inherit" size="small" onClick={() => refetch()}>
|
|
Retry
|
|
</Button>
|
|
}
|
|
sx={{ mb: 2 }}
|
|
>
|
|
Failed to load pricing library. Please try again.
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<TableContainer>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Title</TableCell>
|
|
<TableCell>Category</TableCell>
|
|
<TableCell>Unit</TableCell>
|
|
<TableCell sx={{ textAlign: 'right' }}>Unit Price</TableCell>
|
|
<TableCell>Source</TableCell>
|
|
<TableCell sx={{ width: 100 }} />
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{isLoading
|
|
? Array.from({ length: 5 }).map((_, i) => (
|
|
<TableRow key={i}>
|
|
{Array.from({ length: COL_COUNT }).map((_, j) => (
|
|
<TableCell key={j}>
|
|
<Skeleton variant="text" />
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
: rows.map((item) => {
|
|
const defaultChip = { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' };
|
|
const chipStyle = CATEGORY_CHIP_STYLES[item.serviceCategory] ?? defaultChip;
|
|
return (
|
|
<TableRow key={item.id} hover>
|
|
<TableCell sx={{ fontWeight: 600 }}>{item.title}</TableCell>
|
|
<TableCell>
|
|
<Chip
|
|
label={item.serviceCategory}
|
|
size="small"
|
|
sx={{
|
|
bgcolor: chipStyle.bgcolor,
|
|
color: chipStyle.color,
|
|
border: chipStyle.border,
|
|
fontWeight: 600,
|
|
fontSize: '12px',
|
|
}}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>{item.unit ?? '—'}</TableCell>
|
|
<TableCell sx={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
|
|
{formatPrice(item.unitPrice)}
|
|
</TableCell>
|
|
<TableCell>{item.source}</TableCell>
|
|
<TableCell>
|
|
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
|
<Tooltip title="Edit item" arrow>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => openEditDialog(item)}
|
|
>
|
|
<EditIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip title="Delete item" arrow>
|
|
<IconButton
|
|
size="small"
|
|
onClick={() => openDeleteDialog(item)}
|
|
color="error"
|
|
>
|
|
<DeleteIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
{!isLoading && rows.length === 0 && !isError && (
|
|
<TableRow>
|
|
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
|
|
<Typography color="text.secondary">
|
|
No pricing items yet. Click "New Item" to add one.
|
|
</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
|
|
<TablePagination
|
|
component="div"
|
|
count={totalCount}
|
|
page={page - 1}
|
|
onPageChange={(_, newPage) => setPage(newPage + 1)}
|
|
rowsPerPage={pageSize}
|
|
onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
|
|
rowsPerPageOptions={[12, 24, 48]}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Create / Edit Dialog */}
|
|
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
|
<DialogTitle>{isEditing ? 'Edit Pricing Item' : 'New Pricing Item'}</DialogTitle>
|
|
<DialogContent>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
|
<TextField
|
|
label="Title"
|
|
value={form.title}
|
|
onChange={(e) => handleFieldChange('title', e.target.value)}
|
|
error={!!formErrors.title}
|
|
helperText={formErrors.title}
|
|
size="small"
|
|
required
|
|
fullWidth
|
|
autoFocus
|
|
/>
|
|
<TextField
|
|
label="Service Category"
|
|
value={form.serviceCategory}
|
|
onChange={(e) => handleFieldChange('serviceCategory', e.target.value)}
|
|
error={!!formErrors.serviceCategory}
|
|
helperText={formErrors.serviceCategory}
|
|
size="small"
|
|
required
|
|
fullWidth
|
|
select
|
|
>
|
|
{SERVICE_CATEGORIES.map((cat) => (
|
|
<MenuItem key={cat} value={cat}>
|
|
{cat}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
<TextField
|
|
label="Unit"
|
|
value={form.unit}
|
|
onChange={(e) => handleFieldChange('unit', e.target.value)}
|
|
helperText="e.g., each, hour, sq ft"
|
|
size="small"
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
label="Unit Price"
|
|
type="number"
|
|
value={form.unitPrice}
|
|
onChange={(e) => handleFieldChange('unitPrice', e.target.value)}
|
|
error={!!formErrors.unitPrice}
|
|
helperText={formErrors.unitPrice || 'Optional. Leave blank if price varies.'}
|
|
size="small"
|
|
fullWidth
|
|
slotProps={{
|
|
htmlInput: { min: 0, step: '0.01' },
|
|
}}
|
|
/>
|
|
<TextField
|
|
label="Keywords"
|
|
value={form.keywords}
|
|
onChange={(e) => handleFieldChange('keywords', e.target.value)}
|
|
helperText="Comma-separated keywords for AI matching."
|
|
size="small"
|
|
fullWidth
|
|
multiline
|
|
minRows={2}
|
|
/>
|
|
<TextField
|
|
label="Description"
|
|
value={form.description}
|
|
onChange={(e) => handleFieldChange('description', e.target.value)}
|
|
size="small"
|
|
fullWidth
|
|
multiline
|
|
minRows={2}
|
|
/>
|
|
</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 Item'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirm Dialog */}
|
|
<Dialog open={deleteTarget !== null} onClose={closeDeleteDialog} maxWidth="xs" fullWidth>
|
|
<DialogTitle>Delete Pricing Item</DialogTitle>
|
|
<DialogContent>
|
|
<Typography>
|
|
Are you sure you want to delete <strong>{deleteTarget?.title}</strong>? This action cannot be undone.
|
|
</Typography>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={closeDeleteDialog} disabled={deleteMutation.isPending}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="contained"
|
|
color="error"
|
|
onClick={handleDeleteConfirm}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
}
|