refactor(web): admin workspace on domain layer

This commit is contained in:
Adam Moussa 2026-07-13 18:05:29 -04:00
parent 97fa7f2b75
commit f1087aa82d
No known key found for this signature in database

View file

@ -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<ProposalDetail>({
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<LineItem[]>({
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() {
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={() => saveMutation.mutate()}
onClick={handleSave}
disabled={saveMutation.isPending || !dirty}
>
Save
@ -515,7 +479,7 @@ export default function AdminWorkspace() {
<Button
variant="contained"
startIcon={<PictureAsPdfIcon />}
onClick={() => pdfMutation.mutate()}
onClick={handleGeneratePdf}
disabled={pdfMutation.isPending}
>
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
@ -596,10 +560,10 @@ export default function AdminWorkspace() {
<Button
variant="contained"
color="success"
onClick={() => approveMutation.mutate()}
disabled={approveMutation.isPending}
onClick={handleConfirmApprove}
disabled={approvePending}
>
{approveMutation.isPending ? 'Approving...' : 'Confirm Approve'}
{approvePending ? 'Approving...' : 'Confirm Approve'}
</Button>
</DialogActions>
</Dialog>
@ -622,7 +586,7 @@ export default function AdminWorkspace() {
<Button
variant="contained"
color="success"
onClick={() => sendMutation.mutate()}
onClick={handleConfirmSend}
disabled={sendMutation.isPending}
>
{sendMutation.isPending ? 'Sending...' : 'Confirm Send'}
@ -643,7 +607,7 @@ export default function AdminWorkspace() {
<Button onClick={() => setReviseDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
onClick={() => reviseMutation.mutate()}
onClick={handleConfirmRevise}
disabled={reviseMutation.isPending}
>
{reviseMutation.isPending ? 'Creating...' : 'Confirm Revision'}
@ -665,7 +629,7 @@ export default function AdminWorkspace() {
<Button
variant="contained"
color="warning"
onClick={() => returnToReviewMutation.mutate()}
onClick={handleConfirmReturnToReview}
disabled={returnToReviewMutation.isPending}
>
{returnToReviewMutation.isPending ? 'Returning...' : 'Confirm Return'}