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