proposal-system/web/src/pages/admin/customers/CustomerManagementPage.tsx
Adam Moussa 8dc8f7814b
feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes
Closes WEB-M5 (web hand-duplicated wire types, standing drift risk):

- shared/api-contracts: rewritten as the authoritative superset of the
  .NET DTOs (ProposalListItem/ProposalDetail with poNumber and
  submittedByName, line item requests, customers, pricing library,
  dashboard, audit, sites, auth, presigned upload, ApiProblem); stale
  Proposal/UpdateLineItemsRequest shapes removed
- shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to
  every wire type via `satisfies z.ZodType<T>` (schema/type drift is now
  a compile error); separate entrypoint so type-only consumers (mobile)
  never pull zod
- web: imports @proposal-system/api-contracts (file: dep + tsconfig
  paths + vite preserveSymlinks); all 7 lib/api modules re-export shared
  types so page imports stay stable; enum unions tightened
  (PricingLibraryPage form state now ServiceCategory-typed)
- fix(web): customer create/update sent a singular `address` field the
  API silently dropped (contract is addresses: string[], CustomerDtos.cs)
  - addresses now round-trip, extra addresses preserved on edit
- api: ProblemDetails responses carry a machine-readable top-level
  `code` (SHOC error-code vocabulary): ValidationFailed,
  InvalidStateTransition, NotFound, Unauthorized, InternalError; new
  BusinessRuleException(code, message) maps to 422 with its code;
  GlobalExceptionHandlerTests cover the full mapping (wire contract)

Cross-checked .NET DTOs vs TS types vs zod schemas with the
orchestrator scanner (Gemini): core domains consistent; internal-only
DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos
admin surface) intentionally uncovered.

Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc,
shared tsc all green.
2026-07-13 17:19:42 -04:00

378 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;
// 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.
const payload = {
name: form.name.trim(),
addresses: [form.address.trim(), ...(editingCustomer?.addresses.slice(1) ?? [])],
...(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>
);
}