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.
375 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|