proposal-system/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx
Adam Moussa 36fc1120bf
build(deps): upgrade MUI to v9 (material + icons) and group @mui/* in Dependabot (#168)
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.
2026-06-22 16:02:43 -04:00

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 &quot;New Item&quot; 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>
);
}