2026-06-18 12:40:55 -04:00
|
|
|
import { useState } from 'react';
|
2026-07-13 18:03:12 -04:00
|
|
|
import { useForm } from 'react-hook-form';
|
|
|
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
2026-06-18 12:40:55 -04:00
|
|
|
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';
|
2026-06-22 16:02:43 -04:00
|
|
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
|
2026-06-18 12:40:55 -04:00
|
|
|
import {
|
2026-07-13 18:03:12 -04:00
|
|
|
useCustomerList,
|
|
|
|
|
useCreateCustomer,
|
|
|
|
|
useUpdateCustomer,
|
|
|
|
|
} from '../../../domain/customers/use-cases';
|
|
|
|
|
import type { Customer } from '../../../domain/customers/types';
|
|
|
|
|
import {
|
|
|
|
|
customerFormSchema,
|
|
|
|
|
toCustomerRequest,
|
|
|
|
|
type CustomerFormValues,
|
|
|
|
|
} from '../../../domain/customers/schemas';
|
2026-06-18 12:40:55 -04:00
|
|
|
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants';
|
|
|
|
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
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
|
|
|
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
|
2026-06-18 12:40:55 -04:00
|
|
|
|
|
|
|
|
const COL_COUNT = 4; // Name, Address, Contact Email, Actions
|
|
|
|
|
|
2026-07-13 18:03:12 -04:00
|
|
|
const EMPTY_FORM: CustomerFormValues = { name: '', address: '', contactEmail: '' };
|
2026-06-18 12:40:55 -04:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
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
|
|
|
// 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);
|
2026-06-18 12:40:55 -04:00
|
|
|
|
2026-07-13 18:03:12 -04:00
|
|
|
const { data, isLoading, isError, refetch } = useCustomerList({
|
|
|
|
|
search: debouncedSearch || undefined,
|
|
|
|
|
page,
|
|
|
|
|
pageSize,
|
2026-06-18 12:40:55 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-07-13 18:03:12 -04:00
|
|
|
const {
|
|
|
|
|
register,
|
|
|
|
|
handleSubmit,
|
|
|
|
|
reset,
|
|
|
|
|
formState: { errors },
|
|
|
|
|
} = useForm<CustomerFormValues>({
|
|
|
|
|
resolver: zodResolver(customerFormSchema),
|
|
|
|
|
defaultValues: EMPTY_FORM,
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-18 12:40:55 -04:00
|
|
|
const openCreateDialog = () => {
|
|
|
|
|
setEditingCustomer(null);
|
2026-07-13 18:03:12 -04:00
|
|
|
reset(EMPTY_FORM);
|
2026-06-18 12:40:55 -04:00
|
|
|
setDialogOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const openEditDialog = (customer: Customer) => {
|
|
|
|
|
setEditingCustomer(customer);
|
2026-07-13 18:03:12 -04:00
|
|
|
reset({
|
2026-06-18 12:40:55 -04:00
|
|
|
name: customer.name,
|
|
|
|
|
address: customer.addresses?.[0] ?? '',
|
|
|
|
|
contactEmail: customer.contactEmail ?? '',
|
|
|
|
|
});
|
|
|
|
|
setDialogOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const closeDialog = () => {
|
|
|
|
|
setDialogOpen(false);
|
|
|
|
|
setEditingCustomer(null);
|
2026-07-13 18:03:12 -04:00
|
|
|
reset(EMPTY_FORM);
|
2026-06-18 12:40:55 -04:00
|
|
|
};
|
|
|
|
|
|
2026-07-13 18:03:12 -04:00
|
|
|
// --- Mutations (domain hooks: invalidation + toasts live in the hooks) ---
|
|
|
|
|
const createMutation = useCreateCustomer();
|
|
|
|
|
const updateMutation = useUpdateCustomer();
|
2026-06-18 12:40:55 -04:00
|
|
|
|
2026-07-13 18:03:12 -04:00
|
|
|
const onSubmit = (values: CustomerFormValues) => {
|
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
|
|
|
// Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs);
|
|
|
|
|
// the old singular `address` field was silently dropped by model binding.
|
2026-07-13 18:03:12 -04:00
|
|
|
// The form edits the primary address; any additional addresses are preserved
|
|
|
|
|
// (toCustomerRequest maps to addresses: [primary, ...rest]).
|
|
|
|
|
const payload = toCustomerRequest(values, editingCustomer?.addresses ?? []);
|
2026-06-18 12:40:55 -04:00
|
|
|
|
|
|
|
|
if (isEditing) {
|
2026-07-13 18:03:12 -04:00
|
|
|
updateMutation.mutate(
|
|
|
|
|
{ id: editingCustomer.id, data: payload },
|
|
|
|
|
{ onSuccess: closeDialog }
|
|
|
|
|
);
|
2026-06-18 12:40:55 -04:00
|
|
|
} else {
|
2026-07-13 18:03:12 -04:00
|
|
|
createMutation.mutate(payload, { onSuccess: closeDialog });
|
2026-06-18 12:40:55 -04:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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>
|
2026-07-13 18:03:12 -04:00
|
|
|
<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>
|
2026-06-18 12:40:55 -04:00
|
|
|
</Dialog>
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
}
|