proposal-system/web/src/domain/proposals/use-cases.ts

96 lines
3.4 KiB
TypeScript
Raw Normal View History

// 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
);
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
// 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');
},
});
}