mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 06:58:57 +00:00
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.
95 lines
3.4 KiB
TypeScript
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');
|
|
},
|
|
});
|
|
}
|