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

180 lines
6.2 KiB
TypeScript
Raw Normal View History

// 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 } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { adminApi } from './api';
import { lineItemsApi } from '../lineItems/api';
import { proposalsKeys } from '../proposals/use-cases';
import { lineItemsKeys } from '../lineItems/use-cases';
import type { UpdateProposalRequest } from './types';
import type { UpdateLineItemEntry } from '../lineItems/types';
export const adminKeys = {
all: ['admin'] as const,
dashboard: () => [...adminKeys.all, 'dashboard'] as const,
similar: (proposalId: string) => [...adminKeys.all, 'similar', proposalId] as const,
history: (proposalId: string) => [...adminKeys.all, 'history', proposalId] as const,
audit: (proposalId: string) => [...adminKeys.all, 'audit', proposalId] as const,
};
/** 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 function useProposalHistory(proposalId: string | undefined) {
return useQuery({
queryKey: adminKeys.history(proposalId ?? ''),
queryFn: () => adminApi.getHistory(proposalId!),
enabled: !!proposalId,
});
}
export function useProposalAudit(proposalId: string | undefined) {
return useQuery({
queryKey: adminKeys.audit(proposalId ?? ''),
queryFn: () => adminApi.getAudit(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) => {
await adminApi.updateProposal(proposalId, { refinedScope });
await lineItemsApi.bulkUpdate(proposalId, lineItems);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
toast.success('Changes saved');
},
onError: (error: Error) => {
toast.error(`Save failed: ${error.message}`);
},
});
}
export function useUpdateProposal(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateProposalRequest) => adminApi.updateProposal(proposalId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
},
onError: (error: Error) => {
toast.error(`Save failed: ${error.message}`);
},
});
}
export function useApproveProposal(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminApi.approveProposal(proposalId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
toast.success('Proposal approved');
},
onError: (error: Error) => {
toast.error(`Approval failed: ${error.message}`);
},
});
}
export function useSendProposal(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminApi.sendProposal(proposalId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
toast.success('Proposal marked as sent');
},
onError: (error: Error) => {
toast.error(`Send failed: ${error.message}`);
},
});
}
export function useReviseProposal(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminApi.reviseProposal(proposalId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
toast.success('Revision created');
},
onError: (error: Error) => {
toast.error(`Revision failed: ${error.message}`);
},
});
}
export function useReturnToReview(proposalId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminApi.returnToReview(proposalId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) });
queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) });
toast.success('Proposal returned to review');
},
onError: (error: Error) => {
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');
},
});
}