proposal-system/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx

441 lines
15 KiB
TypeScript

import { useState } from 'react';
import { Controller, useForm, type DefaultValues } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
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 {
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';
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' },
};
/**
* serviceCategory stays union-typed (ServiceCategory) in the form schema, so the
* create defaults leave it unset rather than using an out-of-union '' sentinel.
*/
const EMPTY_FORM_VALUES: DefaultValues<PricingLibraryItemFormValues> = {
title: '',
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 } = usePricingLibraryList({ 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 isEditing = editingItem !== null;
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<PricingLibraryItemFormValues>({
resolver: zodResolver(pricingLibraryItemFormSchema),
defaultValues: EMPTY_FORM_VALUES,
});
const openCreateDialog = () => {
setEditingItem(null);
reset(EMPTY_FORM_VALUES);
setDialogOpen(true);
};
const openEditDialog = (item: PricingLibraryItem) => {
setEditingItem(item);
reset({
title: item.title,
serviceCategory: item.serviceCategory,
unit: item.unit ?? '',
unitPrice: item.unitPrice != null ? String(item.unitPrice) : '',
keywords: item.keywords ?? '',
description: item.description ?? '',
});
setDialogOpen(true);
};
const closeDialog = () => {
setDialogOpen(false);
setEditingItem(null);
reset(EMPTY_FORM_VALUES);
};
// --- Delete confirm dialog state ---
const [deleteTarget, setDeleteTarget] = useState<PricingLibraryItem | null>(null);
const openDeleteDialog = (item: PricingLibraryItem) => {
setDeleteTarget(item);
};
const closeDeleteDialog = () => {
setDeleteTarget(null);
};
// --- Mutations (domain hooks own invalidation + toasts; dialog close stays here) ---
const createMutation = useCreatePricingLibraryItem();
const updateMutation = useUpdatePricingLibraryItem();
const deleteMutation = useDeletePricingLibraryItem();
const onSubmit = handleSubmit((values) => {
const payload = toPricingLibraryItemRequest(values);
if (editingItem) {
updateMutation.mutate(
{ id: editingItem.id, data: payload },
{ onSuccess: () => closeDialog() }
);
} else {
createMutation.mutate(payload, { onSuccess: () => closeDialog() });
}
});
const handleDeleteConfirm = () => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget.id, { onSuccess: () => closeDeleteDialog() });
}
};
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>
<form onSubmit={onSubmit} noValidate>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<Controller
name="title"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Title"
error={!!errors.title}
helperText={errors.title?.message}
size="small"
required
fullWidth
autoFocus
/>
)}
/>
<Controller
name="serviceCategory"
control={control}
render={({ field }) => (
<TextField
{...field}
value={field.value ?? ''}
label="Service Category"
error={!!errors.serviceCategory}
helperText={errors.serviceCategory ? 'Service category is required' : undefined}
size="small"
required
fullWidth
select
>
{SERVICE_CATEGORIES.map((cat) => (
<MenuItem key={cat} value={cat}>
{cat}
</MenuItem>
))}
</TextField>
)}
/>
<Controller
name="unit"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Unit"
helperText="e.g., each, hour, sq ft"
size="small"
fullWidth
/>
)}
/>
<Controller
name="unitPrice"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Unit Price"
type="number"
error={!!errors.unitPrice}
helperText={errors.unitPrice?.message || 'Optional. Leave blank if price varies.'}
size="small"
fullWidth
slotProps={{
htmlInput: { min: 0, step: '0.01' },
}}
/>
)}
/>
<Controller
name="keywords"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Keywords"
helperText="Comma-separated keywords for AI matching."
size="small"
fullWidth
multiline
minRows={2}
/>
)}
/>
<Controller
name="description"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Description"
size="small"
fullWidth
multiline
minRows={2}
/>
)}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={closeDialog} disabled={isSaving}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
disabled={isSaving}
>
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'}
</Button>
</DialogActions>
</form>
</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>
);
}