// Admin domain — TanStack Query hooks + query keys (the only public surface). // State-transition mutations invalidate the proposals/lineItems domain keys // (rule 3 — cross-domain invalidation via the sibling key objects) and carry // the same toasts the AdminWorkspace page shows today. Page-specific side // effects (dialog close, dirty reset, window.open, navigate) stay at the // call site via the mutation's callbacks. import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { adminApi } from './api'; import { lineItemsApi } from '../lineItems/api'; import { proposalsApi } from '../proposals/api'; import { proposalsKeys } from '../proposals/use-cases'; import { lineItemsKeys } from '../lineItems/use-cases'; import { ConflictError } from '../../lib/api/errors'; import type { UpdateLineItemEntry } from '../lineItems/types'; import type { ProposalDetail } from '../proposals/types'; export const adminKeys = { all: ['admin'] as const, dashboard: () => [...adminKeys.all, 'dashboard'] as const, similar: (proposalId: string) => [...adminKeys.all, 'similar', proposalId] as const, }; /** * Every state transition must refresh every view of the proposal: the * workspace (detail + line items) AND the cached queue/list/stats/KPI * queries — lists sit under the global 5-minute staleTime, so without the * broad invalidation an admin returning to the queue after approving sees * stale statuses. */ function invalidateProposalViews(queryClient: QueryClient, proposalId: string) { queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); queryClient.invalidateQueries({ queryKey: proposalsKeys.lists() }); queryClient.invalidateQueries({ queryKey: proposalsKeys.stats() }); queryClient.invalidateQueries({ queryKey: adminKeys.dashboard() }); } /** * The optimistic-concurrency token for a guarded mutation, read from the * cached proposal detail at mutate time (the workspace always fetches the * detail before any action is possible). Read lazily inside mutationFn — * never captured at render — so a save-then-approve chain sees the token the * save wrote back, not the one the page rendered with. */ function cachedProposalVersion(queryClient: QueryClient, proposalId: string): string | undefined { return queryClient.getQueryData(proposalsKeys.detail(proposalId))?.rowVersion; } /** * 409 recovery (refresh-and-retry semantics): write the server's reloaded * currentState into the detail cache (fresh token immediately available), * refetch every proposal view, and tell the user their view was stale. * Returns true when the error was a concurrency conflict — callers skip * their generic failure toast in that case. */ function handleConcurrencyConflict( queryClient: QueryClient, proposalId: string, error: Error, ): boolean { if (!(error instanceof ConflictError)) return false; // Only seed the cache when the embedded state is actually this proposal — // a mismatched or partial envelope falls through to invalidation, and the // refetch restores truth. if (error.currentState && error.currentState.id === proposalId) { queryClient.setQueryData(proposalsKeys.detail(proposalId), error.currentState); } invalidateProposalViews(queryClient, proposalId); toast.warning(error.message); return true; } /** Admin dashboard KPIs (AdminDashboard). */ export function useAdminDashboard() { return useQuery({ queryKey: adminKeys.dashboard(), queryFn: adminApi.getDashboard, }); } /** RAG similarity results (AdminWorkspace, SimilarProposalsPanel). */ export function useSimilarProposals(proposalId: string | undefined) { return useQuery({ queryKey: adminKeys.similar(proposalId ?? ''), queryFn: () => adminApi.getSimilar(proposalId!), enabled: !!proposalId, }); } export interface SaveWorkspaceVariables { refinedScope: string; lineItems: UpdateLineItemEntry[]; } /** * The workspace "Save" action: persist the refined scope, then replace the * proposal's line items (mirrors AdminWorkspace saveMutation exactly). */ export function useSaveProposalWorkspace(proposalId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ refinedScope, lineItems }: SaveWorkspaceVariables) => { // Guarded pair: the PUT rotates the proposal token, so the bulk replace // must send the PUT response's token, not the one the save started with. const updated = await adminApi.updateProposal(proposalId, { refinedScope, proposalVersion: cachedProposalVersion(queryClient, proposalId), }); await lineItemsApi.bulkUpdate(proposalId, lineItems, updated.rowVersion); // The bulk replace rotates the token AGAIN but returns only line items — // refetch the detail so a chained guarded action (approve-after-save) // holds the current token instead of racing the invalidation refetch. // Both writes are already committed here: if only this trailing GET // fails, the save must still report success, with invalidation // recovering the token instead (CONC-L4). try { return await proposalsApi.getById(proposalId); } catch { return null; } }, onSuccess: (fresh) => { if (fresh) { queryClient.setQueryData(proposalsKeys.detail(proposalId), fresh); } else { queryClient.removeQueries({ queryKey: proposalsKeys.detail(proposalId) }); } invalidateProposalViews(queryClient, proposalId); toast.success('Changes saved'); }, onError: (error: Error) => { if (handleConcurrencyConflict(queryClient, proposalId, error)) return; toast.error(`Save failed: ${error.message}`); }, }); } export function useApproveProposal(proposalId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => adminApi.approveProposal(proposalId, cachedProposalVersion(queryClient, proposalId)), onSuccess: (proposal) => { queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal); invalidateProposalViews(queryClient, proposalId); toast.success('Proposal approved'); }, onError: (error: Error) => { if (handleConcurrencyConflict(queryClient, proposalId, error)) return; toast.error(`Approval failed: ${error.message}`); }, }); } export function useSendProposal(proposalId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => adminApi.sendProposal(proposalId, cachedProposalVersion(queryClient, proposalId)), onSuccess: (proposal) => { queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal); invalidateProposalViews(queryClient, proposalId); toast.success('Proposal marked as sent'); }, // Fix: WEB-H5 — mutation must surface failures to the user (relocated // from AdminWorkspace during the domain-layer refactor) onError: (error: Error) => { if (handleConcurrencyConflict(queryClient, proposalId, error)) return; toast.error(`Send failed: ${error.message}`); }, }); } export function useReviseProposal(proposalId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => adminApi.reviseProposal(proposalId, cachedProposalVersion(queryClient, proposalId)), onSuccess: (revision) => { // The response is the NEW revision (different id) — seed its detail // cache; the invalidation below refreshes the original's views. queryClient.setQueryData(proposalsKeys.detail(revision.id), revision); invalidateProposalViews(queryClient, proposalId); toast.success('Revision created'); }, // Fix: WEB-H6 — mutation must surface failures to the user (relocated // from AdminWorkspace during the domain-layer refactor) onError: (error: Error) => { if (handleConcurrencyConflict(queryClient, proposalId, error)) return; toast.error(`Revision failed: ${error.message}`); }, }); } export function useReturnToReview(proposalId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => adminApi.returnToReview(proposalId, cachedProposalVersion(queryClient, proposalId)), onSuccess: (proposal) => { queryClient.setQueryData(proposalsKeys.detail(proposal.id), proposal); invalidateProposalViews(queryClient, proposalId); toast.success('Proposal returned to review'); }, onError: (error: Error) => { if (handleConcurrencyConflict(queryClient, proposalId, error)) return; toast.error(`Return to review failed: ${error.message}`); }, }); } export function useGenerateSuggestions(proposalId: string) { return useMutation({ mutationFn: () => adminApi.generateSuggestions(proposalId), onSuccess: () => { toast.info('AI suggestion generation started'); }, onError: (error: Error) => { toast.error(`Regeneration failed: ${error.message}`); }, }); } /** * Fetch (or queue generation of) the branded PDF. Resolves null while * generation is queued (202). Success handling — open the download and * toast — is page behavior and belongs at the call site. */ export function useProposalPdf(proposalId: string) { return useMutation({ mutationFn: (regenerate?: boolean) => adminApi.getPdf(proposalId, regenerate ?? false), onError: () => { toast.error('Failed to generate PDF'); }, }); }