mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 15:08:58 +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 { 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'}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue