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 { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { import {
Box, Box,
Typography, Typography,
@ -31,16 +30,39 @@ import HistoryIcon from '@mui/icons-material/History';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import UndoIcon from '@mui/icons-material/Undo'; import UndoIcon from '@mui/icons-material/Undo';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; import { useProposal } from '../../../domain/proposals/use-cases';
import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems'; import { useLineItems } from '../../../domain/lineItems/use-cases';
import { adminApi } from '../../../lib/api/admin'; import type { UpdateLineItemEntry } from '../../../domain/lineItems/types';
import { queryClient } from '../../../lib/queryClient'; import {
import { QUERY_KEYS } from '../../../constants/queryKeys'; useSimilarProposals,
useSaveProposalWorkspace,
useApproveProposal,
useSendProposal,
useReviseProposal,
useReturnToReview,
useGenerateSuggestions,
useProposalPdf,
} from '../../../domain/admin/use-cases';
import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor'; import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor';
import VendorDataPanel from '../../../components/admin/VendorDataPanel'; import VendorDataPanel from '../../../components/admin/VendorDataPanel';
import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel'; import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel';
import { STATUS_CHIP_STYLES } from '../../../constants'; 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() { export default function AdminWorkspace() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@ -55,24 +77,18 @@ export default function AdminWorkspace() {
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
// Fix: WEB-M7 — capture error state for failed fetch // Fix: WEB-M7 — capture error state for failed fetch
const { data: proposal, isLoading, isError: isProposalError, error: proposalError, refetch: refetchProposal } = useQuery<ProposalDetail>({ const {
queryKey: [QUERY_KEYS.proposals, id], data: proposal,
queryFn: () => proposalsApi.getById(id!), isLoading,
enabled: !!id, isError: isProposalError,
}); error: proposalError,
refetch: refetchProposal,
} = useProposal(id);
// Fix: WEB-M9 — capture loading state for line items skeleton // Fix: WEB-M9 — capture loading state for line items skeleton
const { data: fetchedLineItems, isLoading: lineItemsLoading } = useQuery<LineItem[]>({ const { data: fetchedLineItems, isLoading: lineItemsLoading } = useLineItems(id);
queryKey: [QUERY_KEYS.proposalLineItems, id],
queryFn: () => lineItemsApi.getAll(id!),
enabled: !!id,
});
const { data: similarProposals } = useQuery({ const { data: similarProposals } = useSimilarProposals(id);
queryKey: ['similarProposals', id],
queryFn: () => adminApi.getSimilar(id!),
enabled: !!id,
});
const hasSimilar = similarProposals && similarProposals.length > 0; const hasSimilar = similarProposals && similarProposals.length > 0;
@ -121,107 +137,57 @@ export default function AdminWorkspace() {
} }
}, [dirty, navigate]); }, [dirty, navigate]);
const saveMutation = useMutation({ // Domain mutations: cache invalidation + success/error toasts live in the
mutationFn: async () => { // hooks; page-specific side effects (dialog close, dirty reset, window.open)
await adminApi.updateProposal(id!, { refinedScope }); // are applied here via the call-site callbacks (domain README rule 6).
await lineItemsApi.bulkUpdate( const saveMutation = useSaveProposalWorkspace(id ?? '');
id!, const approveMutation = useApproveProposal(id ?? '');
lineItems.map((li) => ({ const sendMutation = useSendProposal(id ?? '');
id: li.id, const reviseMutation = useReviseProposal(id ?? '');
description: li.description, const regenerateMutation = useGenerateSuggestions(id ?? '');
quantity: li.quantity, // Fix: WEB-M11 — wire returnToReview API method to UI
unit: li.unit, const returnToReviewMutation = useReturnToReview(id ?? '');
unitPrice: li.unitPrice, const pdfMutation = useProposalPdf(id ?? '');
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}`);
},
});
const approveMutation = useMutation({ const handleSave = () => {
mutationFn: async () => { 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) { if (dirty) {
await saveMutation.mutateAsync(); await saveMutation.mutateAsync({ refinedScope, lineItems: toUpdateEntries(lineItems) });
} }
await adminApi.approveProposal(id!); await approveMutation.mutateAsync();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] });
setApproveDialogOpen(false); setApproveDialogOpen(false);
setDirty(false); setDirty(false);
toast.success('Proposal approved'); } catch {
}, // error toasts are handled by the domain hooks
onError: (error: Error) => { }
toast.error(`Approval failed: ${error.message}`); };
}, const approvePending = approveMutation.isPending || saveMutation.isPending;
});
const sendMutation = useMutation({ const handleConfirmSend = () => {
mutationFn: () => adminApi.sendProposal(id!), sendMutation.mutate(undefined, { onSuccess: () => setSendDialogOpen(false) });
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 reviseMutation = useMutation({ const handleConfirmRevise = () => {
mutationFn: () => adminApi.reviseProposal(id!), reviseMutation.mutate(undefined, { onSuccess: () => setReviseDialogOpen(false) });
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 regenerateMutation = useMutation({ const handleConfirmReturnToReview = () => {
mutationFn: () => adminApi.generateSuggestions(id!), returnToReviewMutation.mutate(undefined, {
onSuccess: () => { onSuccess: () => setReturnToReviewDialogOpen(false),
toast.info('AI suggestion generation started');
},
// Fix: WEB-H5 — add missing onError handler
onError: (error: Error) => {
toast.error(`Regeneration failed: ${error.message}`);
},
}); });
};
// Fix: WEB-M11 — wire returnToReview API method to UI const handleGeneratePdf = () => {
const returnToReviewMutation = useMutation({ pdfMutation.mutate(undefined, {
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) => { onSuccess: (data) => {
if (data?.downloadUrl) { if (data?.downloadUrl) {
window.open(data.downloadUrl, '_blank'); window.open(data.downloadUrl, '_blank');
@ -230,10 +196,8 @@ export default function AdminWorkspace() {
toast.info('PDF generation queued — try again in a few seconds'); toast.info('PDF generation queued — try again in a few seconds');
} }
}, },
onError: () => {
toast.error('Failed to generate PDF');
},
}); });
};
const handleLineItemsChange = (updated: EditableLineItem[]) => { const handleLineItemsChange = (updated: EditableLineItem[]) => {
setLineItems(updated); setLineItems(updated);
@ -478,7 +442,7 @@ export default function AdminWorkspace() {
<Button <Button
variant="contained" variant="contained"
startIcon={<SaveIcon />} startIcon={<SaveIcon />}
onClick={() => saveMutation.mutate()} onClick={handleSave}
disabled={saveMutation.isPending || !dirty} disabled={saveMutation.isPending || !dirty}
> >
Save Save
@ -515,7 +479,7 @@ export default function AdminWorkspace() {
<Button <Button
variant="contained" variant="contained"
startIcon={<PictureAsPdfIcon />} startIcon={<PictureAsPdfIcon />}
onClick={() => pdfMutation.mutate()} onClick={handleGeneratePdf}
disabled={pdfMutation.isPending} disabled={pdfMutation.isPending}
> >
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'} {pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
@ -596,10 +560,10 @@ export default function AdminWorkspace() {
<Button <Button
variant="contained" variant="contained"
color="success" color="success"
onClick={() => approveMutation.mutate()} onClick={handleConfirmApprove}
disabled={approveMutation.isPending} disabled={approvePending}
> >
{approveMutation.isPending ? 'Approving...' : 'Confirm Approve'} {approvePending ? 'Approving...' : 'Confirm Approve'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
@ -622,7 +586,7 @@ export default function AdminWorkspace() {
<Button <Button
variant="contained" variant="contained"
color="success" color="success"
onClick={() => sendMutation.mutate()} onClick={handleConfirmSend}
disabled={sendMutation.isPending} disabled={sendMutation.isPending}
> >
{sendMutation.isPending ? 'Sending...' : 'Confirm Send'} {sendMutation.isPending ? 'Sending...' : 'Confirm Send'}
@ -643,7 +607,7 @@ export default function AdminWorkspace() {
<Button onClick={() => setReviseDialogOpen(false)}>Cancel</Button> <Button onClick={() => setReviseDialogOpen(false)}>Cancel</Button>
<Button <Button
variant="contained" variant="contained"
onClick={() => reviseMutation.mutate()} onClick={handleConfirmRevise}
disabled={reviseMutation.isPending} disabled={reviseMutation.isPending}
> >
{reviseMutation.isPending ? 'Creating...' : 'Confirm Revision'} {reviseMutation.isPending ? 'Creating...' : 'Confirm Revision'}
@ -665,7 +629,7 @@ export default function AdminWorkspace() {
<Button <Button
variant="contained" variant="contained"
color="warning" color="warning"
onClick={() => returnToReviewMutation.mutate()} onClick={handleConfirmReturnToReview}
disabled={returnToReviewMutation.isPending} disabled={returnToReviewMutation.isPending}
> >
{returnToReviewMutation.isPending ? 'Returning...' : 'Confirm Return'} {returnToReviewMutation.isPending ? 'Returning...' : 'Confirm Return'}