proposal-system/web/src/pages/admin/customers/CustomerManagementPage.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

375 lines
12 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,
InputAdornment,
Button,
Skeleton,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
import { toast } from 'react-toastify';
import {
customersApi,
type Customer,
type PagedCustomerResponse,
type CreateCustomerRequest,
type UpdateCustomerRequest,
} from '../../../lib/api/customers';
import { queryClient } from '../../../lib/queryClient';
import { QUERY_KEYS } from '../../../constants/queryKeys';
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
const COL_COUNT = 4; // Name, Address, Contact Email, Actions
/** Simple email format check (client-side only). */
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
interface CustomerFormState {
name: string;
address: string;
contactEmail: string;
}
const EMPTY_FORM: CustomerFormState = { name: '', address: '', contactEmail: '' };
export default function CustomerManagementPage() {
useDocumentTitle('Customer Management');
// --- List state ---
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// Debounce search input
const [debounceTimer, setDebounceTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const handleSearchChange = (value: string) => {
setSearch(value);
if (debounceTimer) clearTimeout(debounceTimer);
const timer = setTimeout(() => {
setDebouncedSearch(value);
setPage(1);
}, DEBOUNCE_SEARCH);
setDebounceTimer(timer);
};
const {
data,
isLoading,
isError,
refetch,
} = useQuery<PagedCustomerResponse>({
queryKey: [QUERY_KEYS.customerList, debouncedSearch, page, pageSize],
queryFn: () => customersApi.list({ search: debouncedSearch || undefined, page, pageSize }),
});
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
// --- Dialog state ---
const [dialogOpen, setDialogOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [form, setForm] = useState<CustomerFormState>(EMPTY_FORM);
const [formErrors, setFormErrors] = useState<Partial<Record<keyof CustomerFormState, string>>>({});
const isEditing = editingCustomer !== null;
const openCreateDialog = () => {
setEditingCustomer(null);
setForm(EMPTY_FORM);
setFormErrors({});
setDialogOpen(true);
};
const openEditDialog = (customer: Customer) => {
setEditingCustomer(customer);
setForm({
name: customer.name,
address: customer.addresses?.[0] ?? '',
contactEmail: customer.contactEmail ?? '',
});
setFormErrors({});
setDialogOpen(true);
};
const closeDialog = () => {
setDialogOpen(false);
setEditingCustomer(null);
setForm(EMPTY_FORM);
setFormErrors({});
};
const validateForm = (): boolean => {
const errors: Partial<Record<keyof CustomerFormState, string>> = {};
if (!form.name.trim()) errors.name = 'Name is required';
if (!form.address.trim()) errors.address = 'Address is required';
if (form.contactEmail.trim() && !isValidEmail(form.contactEmail.trim())) {
errors.contactEmail = 'Enter a valid email address';
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleFieldChange = (field: keyof CustomerFormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
// Clear field error on change
if (formErrors[field]) {
setFormErrors((prev) => {
const next = { ...prev };
delete next[field];
return next;
});
}
};
// --- Mutations ---
const createMutation = useMutation({
mutationFn: (data: CreateCustomerRequest) => customersApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.customerList] });
closeDialog();
toast.success('Customer created');
},
onError: (error: Error) => {
toast.error(`Failed to create customer: ${error.message}`);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) =>
customersApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.customerList] });
closeDialog();
toast.success('Customer updated');
},
onError: (error: Error) => {
toast.error(`Failed to update customer: ${error.message}`);
},
});
const handleSubmit = () => {
if (!validateForm()) return;
const payload = {
name: form.name.trim(),
address: form.address.trim(),
...(form.contactEmail.trim() ? { contactEmail: form.contactEmail.trim() } : {}),
};
if (isEditing) {
updateMutation.mutate({ id: editingCustomer.id, data: payload });
} else {
createMutation.mutate(payload);
}
};
const isSaving = createMutation.isPending || updateMutation.isPending;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h5">Customer Management</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={openCreateDialog}
>
New Customer
</Button>
</Box>
<Card>
<CardContent sx={{ pb: 0 }}>
{/* Search bar */}
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
<TextField
size="small"
placeholder="Search customers..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
sx={{ width: 300 }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
},
}}
/>
</Box>
{/* Error state */}
{isError && (
<Alert
severity="error"
icon={<ErrorOutlineIcon />}
action={
<Button color="inherit" size="small" onClick={() => refetch()}>
Retry
</Button>
}
sx={{ mb: 2 }}
>
Failed to load customers. Please try again.
</Alert>
)}
{/* Table */}
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Address</TableCell>
<TableCell>Contact Email</TableCell>
<TableCell sx={{ width: 60 }} />
</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((customer) => (
<TableRow key={customer.id} hover>
<TableCell sx={{ fontWeight: 600 }}>{customer.name}</TableCell>
<TableCell>{customer.addresses?.[0] ?? '-'}</TableCell>
<TableCell>
{customer.contactEmail ? (
customer.contactEmail
) : (
<Typography
component="span"
sx={{ color: '#94A3B8', fontStyle: 'italic', fontSize: 'inherit' }}
>
No email
</Typography>
)}
</TableCell>
<TableCell>
<Tooltip title="Edit customer" arrow>
<IconButton
size="small"
onClick={() => openEditDialog(customer)}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
{!isLoading && rows.length === 0 && !isError && (
<TableRow>
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">
{debouncedSearch
? 'No customers match your search.'
: 'No customers yet. Click "New Customer" 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 Customer' : 'New Customer'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Name"
value={form.name}
onChange={(e) => handleFieldChange('name', e.target.value)}
error={!!formErrors.name}
helperText={formErrors.name}
size="small"
required
fullWidth
autoFocus
/>
<TextField
label="Address"
value={form.address}
onChange={(e) => handleFieldChange('address', e.target.value)}
error={!!formErrors.address}
helperText={formErrors.address}
size="small"
required
fullWidth
/>
<TextField
label="Contact Email"
type="email"
value={form.contactEmail}
onChange={(e) => handleFieldChange('contactEmail', e.target.value)}
error={!!formErrors.contactEmail}
helperText={formErrors.contactEmail || 'Optional. Used for proposal PDF delivery.'}
size="small"
fullWidth
/>
</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 Customer'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}