diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index 87729e8..b73a3a5 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { useQuery, useMutation } from '@tanstack/react-query'; import { Box, Typography, @@ -31,16 +30,39 @@ import HistoryIcon from '@mui/icons-material/History'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import UndoIcon from '@mui/icons-material/Undo'; import { toast } from 'react-toastify'; -import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; -import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems'; -import { adminApi } from '../../../lib/api/admin'; -import { queryClient } from '../../../lib/queryClient'; -import { QUERY_KEYS } from '../../../constants/queryKeys'; +import { useProposal } from '../../../domain/proposals/use-cases'; +import { useLineItems } from '../../../domain/lineItems/use-cases'; +import type { UpdateLineItemEntry } from '../../../domain/lineItems/types'; +import { + useSimilarProposals, + useSaveProposalWorkspace, + useApproveProposal, + useSendProposal, + useReviseProposal, + useReturnToReview, + useGenerateSuggestions, + useProposalPdf, +} from '../../../domain/admin/use-cases'; import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor'; import VendorDataPanel from '../../../components/admin/VendorDataPanel'; import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel'; import { STATUS_CHIP_STYLES } from '../../../constants'; +/** Map the editor's rows to the bulk-update wire entries. */ +function toUpdateEntries(items: EditableLineItem[]): UpdateLineItemEntry[] { + return items.map((li) => ({ + id: li.id, + description: li.description, + quantity: li.quantity, + unit: li.unit, + unitPrice: li.unitPrice, + totalPrice: li.totalPrice, + pricingMode: li.pricingMode, + sortOrder: li.sortOrder, + source: li.source, + })); +} + export default function AdminWorkspace() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -55,24 +77,18 @@ export default function AdminWorkspace() { const [dirty, setDirty] = useState(false); // Fix: WEB-M7 — capture error state for failed fetch - const { data: proposal, isLoading, isError: isProposalError, error: proposalError, refetch: refetchProposal } = useQuery({ - queryKey: [QUERY_KEYS.proposals, id], - queryFn: () => proposalsApi.getById(id!), - enabled: !!id, - }); + const { + data: proposal, + isLoading, + isError: isProposalError, + error: proposalError, + refetch: refetchProposal, + } = useProposal(id); // Fix: WEB-M9 — capture loading state for line items skeleton - const { data: fetchedLineItems, isLoading: lineItemsLoading } = useQuery({ - queryKey: [QUERY_KEYS.proposalLineItems, id], - queryFn: () => lineItemsApi.getAll(id!), - enabled: !!id, - }); + const { data: fetchedLineItems, isLoading: lineItemsLoading } = useLineItems(id); - const { data: similarProposals } = useQuery({ - queryKey: ['similarProposals', id], - queryFn: () => adminApi.getSimilar(id!), - enabled: !!id, - }); + const { data: similarProposals } = useSimilarProposals(id); const hasSimilar = similarProposals && similarProposals.length > 0; @@ -121,119 +137,67 @@ export default function AdminWorkspace() { } }, [dirty, navigate]); - const saveMutation = useMutation({ - mutationFn: async () => { - await adminApi.updateProposal(id!, { refinedScope }); - await lineItemsApi.bulkUpdate( - id!, - lineItems.map((li) => ({ - id: li.id, - description: li.description, - quantity: li.quantity, - unit: li.unit, - unitPrice: li.unitPrice, - totalPrice: li.totalPrice, - pricingMode: li.pricingMode, - sortOrder: li.sortOrder, - source: li.source, - })) - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - setDirty(false); - toast.success('Changes saved'); - }, - onError: (error: Error) => { - toast.error(`Save failed: ${error.message}`); - }, - }); + // Domain mutations: cache invalidation + success/error toasts live in the + // hooks; page-specific side effects (dialog close, dirty reset, window.open) + // are applied here via the call-site callbacks (domain README rule 6). + const saveMutation = useSaveProposalWorkspace(id ?? ''); + const approveMutation = useApproveProposal(id ?? ''); + const sendMutation = useSendProposal(id ?? ''); + const reviseMutation = useReviseProposal(id ?? ''); + const regenerateMutation = useGenerateSuggestions(id ?? ''); + // Fix: WEB-M11 — wire returnToReview API method to UI + const returnToReviewMutation = useReturnToReview(id ?? ''); + const pdfMutation = useProposalPdf(id ?? ''); - const approveMutation = useMutation({ - mutationFn: async () => { + const handleSave = () => { + saveMutation.mutate( + { refinedScope, lineItems: toUpdateEntries(lineItems) }, + { onSuccess: () => setDirty(false) } + ); + }; + + const handleConfirmApprove = async () => { + try { + // Persist any unsaved workspace edits before approving (behavior parity + // with the previous approve mutation, which saved first when dirty). if (dirty) { - await saveMutation.mutateAsync(); + await saveMutation.mutateAsync({ refinedScope, lineItems: toUpdateEntries(lineItems) }); } - await adminApi.approveProposal(id!); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); + await approveMutation.mutateAsync(); setApproveDialogOpen(false); setDirty(false); - toast.success('Proposal approved'); - }, - onError: (error: Error) => { - toast.error(`Approval failed: ${error.message}`); - }, - }); + } catch { + // error toasts are handled by the domain hooks + } + }; + const approvePending = approveMutation.isPending || saveMutation.isPending; - const sendMutation = useMutation({ - mutationFn: () => adminApi.sendProposal(id!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - setSendDialogOpen(false); - toast.success('Proposal marked as sent'); - }, - // Fix: WEB-H5 — add missing onError handler - onError: (error: Error) => { - toast.error(`Send failed: ${error.message}`); - }, - }); + const handleConfirmSend = () => { + sendMutation.mutate(undefined, { onSuccess: () => setSendDialogOpen(false) }); + }; - const reviseMutation = useMutation({ - mutationFn: () => adminApi.reviseProposal(id!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - setReviseDialogOpen(false); - toast.success('Revision created'); - }, - // Fix: WEB-H6 — add missing onError handler - onError: (error: Error) => { - toast.error(`Revision failed: ${error.message}`); - }, - }); + const handleConfirmRevise = () => { + reviseMutation.mutate(undefined, { onSuccess: () => setReviseDialogOpen(false) }); + }; - const regenerateMutation = useMutation({ - mutationFn: () => adminApi.generateSuggestions(id!), - onSuccess: () => { - toast.info('AI suggestion generation started'); - }, - // Fix: WEB-H5 — add missing onError handler - onError: (error: Error) => { - toast.error(`Regeneration failed: ${error.message}`); - }, - }); + const handleConfirmReturnToReview = () => { + returnToReviewMutation.mutate(undefined, { + onSuccess: () => setReturnToReviewDialogOpen(false), + }); + }; - // Fix: WEB-M11 — wire returnToReview API method to UI - const returnToReviewMutation = useMutation({ - mutationFn: () => adminApi.returnToReview(id!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); - setReturnToReviewDialogOpen(false); - toast.success('Proposal returned to review'); - }, - onError: (error: Error) => { - toast.error(`Return to review failed: ${error.message}`); - }, - }); - - const pdfMutation = useMutation({ - mutationFn: () => adminApi.getPdf(id!), - onSuccess: (data) => { - if (data?.downloadUrl) { - window.open(data.downloadUrl, '_blank'); - toast.success('PDF ready — downloading'); - } else { - toast.info('PDF generation queued — try again in a few seconds'); - } - }, - onError: () => { - toast.error('Failed to generate PDF'); - }, - }); + const handleGeneratePdf = () => { + pdfMutation.mutate(undefined, { + onSuccess: (data) => { + if (data?.downloadUrl) { + window.open(data.downloadUrl, '_blank'); + toast.success('PDF ready — downloading'); + } else { + toast.info('PDF generation queued — try again in a few seconds'); + } + }, + }); + }; const handleLineItemsChange = (updated: EditableLineItem[]) => { setLineItems(updated); @@ -478,7 +442,7 @@ export default function AdminWorkspace() { @@ -622,7 +586,7 @@ export default function AdminWorkspace() {