proposal-system/web/src/domain/proposals/use-cases.ts
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

95 lines
3.4 KiB
TypeScript

// Proposals domain — TanStack Query hooks + query keys (the only public surface).
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { proposalsApi } from './api';
import type { CreateProposalRequest, ProposalDetail, ProposalFilters } from './types';
export const proposalsKeys = {
all: ['proposals'] as const,
lists: () => [...proposalsKeys.all, 'list'] as const,
list: (filters: ProposalFilters) => [...proposalsKeys.lists(), filters] as const,
detail: (id: string) => [...proposalsKeys.all, id] as const,
stats: () => [...proposalsKeys.all, 'stats'] as const,
vendors: (id: string) => [...proposalsKeys.all, id, 'vendors'] as const,
};
/** Paginated/filtered proposal list (ProposalListPage, AdminDashboard queue,
* Dashboard recent). keepPreviousData mirrors usePaginatedList's
* keep-rows-while-loading behavior across page changes. */
export function useProposals(filters: ProposalFilters = {}) {
return useQuery({
queryKey: proposalsKeys.list(filters),
queryFn: () => proposalsApi.getAll(filters),
placeholderData: keepPreviousData,
});
}
export function useProposal(id: string | undefined) {
return useQuery({
queryKey: proposalsKeys.detail(id ?? ''),
queryFn: () => proposalsApi.getById(id!),
enabled: !!id,
});
}
export function useProposalStats() {
return useQuery({
queryKey: proposalsKeys.stats(),
queryFn: () => proposalsApi.getStats(),
});
}
/** Vendor proposals attached to a proposal (VendorDataPanel). */
export function useVendorProposals(proposalId: string | undefined) {
return useQuery({
queryKey: proposalsKeys.vendors(proposalId ?? ''),
queryFn: () => proposalsApi.getVendors(proposalId!),
enabled: !!proposalId,
});
}
export interface CreateProposalVariables {
request: CreateProposalRequest;
/** Optional vendor PDF uploaded alongside the proposal. */
vendorFile?: File | null;
}
/**
* Create a proposal, optionally uploading a vendor PDF via presigned URL.
* Mirrors ProposalFormPage: a failed attachment upload warns but does not
* fail the mutation; success invalidates all proposal queries and toasts.
* Page-specific side effects (navigate) belong in the call site's onSuccess.
*/
export function useCreateProposal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ request, vendorFile }: CreateProposalVariables): Promise<ProposalDetail> => {
const proposal = await proposalsApi.create(request);
if (vendorFile) {
try {
const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment(
proposal.id,
vendorFile.name
);
// Throws on S3 rejection (non-2xx) so a failed upload is never confirmed.
await proposalsApi.uploadToPresignedUrl(uploadUrl, vendorFile);
await proposalsApi.confirmUpload(proposal.id, vendorProposalId);
} catch {
toast.warning(
`Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.`
);
}
}
return proposal;
},
onSuccess: (proposal) => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.all });
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to submit proposal');
},
});
}