proposal-system/web/src/pages/admin/customers/CustomerManagementPage.tsx
Adam Moussa 1f55a59445
fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup)
Correctness:
- State-transition mutations now invalidate every cached view via
  invalidateProposalViews (detail + line items + lists + stats + admin
  dashboard) — approving no longer leaves a stale queue for the
  5-minute staleTime
- Presigned S3 PUT moved to proposals/api.ts with res.ok check — a
  rejected upload is no longer confirmed as uploaded
- toCustomerRequest always sends contactEmail ('' clears); API create
  path normalizes empty->null to match the update path — customer
  emails can now be cleared from the UI
- Shared Number-based numeric form fields (domain/shared/formFields):
  '12abc' no longer silently coerces to 12 in the pricing library
- Customer create/update invalidate customersKeys.all so cached search
  autocompletes see new customers
- AdminWorkspace clears dirty right after a successful implicit save,
  before approve — no false unsaved-changes prompt when approve fails
- ProposalFormPage submit gate and missing-fields caption derive from
  ONE checks list (missing customer is now listed)
- Empty states gated on !err in ProposalListPage/AdminDashboard — no
  contradictory error + 'no proposals' UI
- VendorDataPanel migrated to useVendorProposals (kills the divergent
  ['vendorProposals', id] cache key and the inline apiClient query)
- useCustomerList/usePricingLibraryList get keepPreviousData — no
  TablePagination out-of-range flash on page change

Cleanup:
- Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem,
  useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema,
  toUpdateLineItemEntry); tests moved to the live save path
  (useSaveProposalWorkspace)
- Shared useDebouncedValue hook replaces 4 drifted inline debounce
  copies (one leaked its timer on unmount, two hardcoded 300ms);
  DEBOUNCE_AUTOCOMPLETE=300 named
- Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated
  onError handlers (CLAUDE.md traceability)
- shared/api-contracts gains an exports map; /schemas resolver alias
  deduplicated from 3 copies to the tsconfig paths mapping

Verify: tsc clean, vitest 51/51 (tests updated to pin the new
invalidation/mapper behavior + new '12abc' rejection test),
vite build OK, dotnet 166/166.
2026-07-13 19:06:08 -04:00

314 lines
10 KiB
TypeScript

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
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 {
useCustomerList,
useCreateCustomer,
useUpdateCustomer,
} from '../../../domain/customers/use-cases';
import type { Customer } from '../../../domain/customers/types';
import {
customerFormSchema,
toCustomerRequest,
type CustomerFormValues,
} from '../../../domain/customers/schemas';
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
const COL_COUNT = 4; // Name, Address, Contact Email, Actions
const EMPTY_FORM: CustomerFormValues = { name: '', address: '', contactEmail: '' };
export default function CustomerManagementPage() {
useDocumentTitle('Customer Management');
// --- List state ---
const [search, setSearch] = useState('');
const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// Debounced search resets to page 1 (shared hook — the previous inline
// timer leaked on unmount).
const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1));
const handleSearchChange = (value: string) => setSearch(value);
const { data, isLoading, isError, refetch } = useCustomerList({
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 isEditing = editingCustomer !== null;
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CustomerFormValues>({
resolver: zodResolver(customerFormSchema),
defaultValues: EMPTY_FORM,
});
const openCreateDialog = () => {
setEditingCustomer(null);
reset(EMPTY_FORM);
setDialogOpen(true);
};
const openEditDialog = (customer: Customer) => {
setEditingCustomer(customer);
reset({
name: customer.name,
address: customer.addresses?.[0] ?? '',
contactEmail: customer.contactEmail ?? '',
});
setDialogOpen(true);
};
const closeDialog = () => {
setDialogOpen(false);
setEditingCustomer(null);
reset(EMPTY_FORM);
};
// --- Mutations (domain hooks: invalidation + toasts live in the hooks) ---
const createMutation = useCreateCustomer();
const updateMutation = useUpdateCustomer();
const onSubmit = (values: CustomerFormValues) => {
// Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs);
// the old singular `address` field was silently dropped by model binding.
// The form edits the primary address; any additional addresses are preserved
// (toCustomerRequest maps to addresses: [primary, ...rest]).
const payload = toCustomerRequest(values, editingCustomer?.addresses ?? []);
if (isEditing) {
updateMutation.mutate(
{ id: editingCustomer.id, data: payload },
{ onSuccess: closeDialog }
);
} else {
createMutation.mutate(payload, { onSuccess: closeDialog });
}
};
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>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<DialogTitle>{isEditing ? 'Edit Customer' : 'New Customer'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Name"
{...register('name')}
error={!!errors.name}
helperText={errors.name?.message}
size="small"
required
fullWidth
autoFocus
/>
<TextField
label="Address"
{...register('address')}
error={!!errors.address}
helperText={errors.address?.message}
size="small"
required
fullWidth
/>
<TextField
label="Contact Email"
type="email"
{...register('contactEmail')}
error={!!errors.contactEmail}
helperText={errors.contactEmail?.message || 'Optional. Used for proposal PDF delivery.'}
size="small"
fullWidth
/>
</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 Customer'}
</Button>
</DialogActions>
</form>
</Dialog>
</Box>
);
}