mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 09:19:04 +00:00
refactor(web): admin workspace on domain layer
This commit is contained in:
parent
97fa7f2b75
commit
f1087aa82d
1 changed files with 97 additions and 133 deletions
|
|
@ -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'}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue