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

99 lines
3.5 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
);
await fetch(uploadUrl, {
method: 'PUT',
body: vendorFile,
headers: { 'Content-Type': vendorFile.type || 'application/pdf' },
});
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');
},
});
}