mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 11:13:14 +00:00
WEB-M2: 401 interceptor now dispatches Redux logout action to clear auth state, not just localStorage. WEB-M5: CreateProposalRequest uses typed ServiceCategory and Priority unions aligned with shared/api-contracts contract. WEB-M6: Vendor PDF upload validates MIME type (application/pdf), file extension (.pdf), and max size (25 MB) before accepting. WEB-M7: AdminWorkspace shows error Alert with retry button when proposal fetch fails, instead of rendering empty workspace. WEB-M10: State transition buttons (Approve, Send, Revise) are disabled with explanatory tooltips when proposal is not in the correct state for that transition. WEB-M13: ToastContainer moved inside BrowserRouter so toasts render in the correct React tree context.
553 lines
20 KiB
TypeScript
553 lines
20 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useParams, useNavigate, useBlocker } from 'react-router-dom';
|
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Card,
|
|
CardContent,
|
|
Grid,
|
|
Button,
|
|
Chip,
|
|
Divider,
|
|
TextField,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
CircularProgress,
|
|
Alert,
|
|
Tooltip,
|
|
} from '@mui/material';
|
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
import SaveIcon from '@mui/icons-material/Save';
|
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
import SendIcon from '@mui/icons-material/Send';
|
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
import HistoryIcon from '@mui/icons-material/History';
|
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
|
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 LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor';
|
|
import VendorDataPanel from '../../../components/admin/VendorDataPanel';
|
|
import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel';
|
|
import { STATUS_COLORS } from '../../../constants';
|
|
|
|
export default function AdminWorkspace() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
|
|
const [refinedScope, setRefinedScope] = useState('');
|
|
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
|
|
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
|
const [reviseDialogOpen, setReviseDialogOpen] = useState(false);
|
|
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: fetchedLineItems } = useQuery<LineItem[]>({
|
|
queryKey: [QUERY_KEYS.proposalLineItems, id],
|
|
queryFn: () => lineItemsApi.getAll(id!),
|
|
enabled: !!id,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (fetchedLineItems) {
|
|
setLineItems(
|
|
fetchedLineItems.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,
|
|
}))
|
|
);
|
|
}
|
|
}, [fetchedLineItems]);
|
|
|
|
useEffect(() => {
|
|
if (proposal) {
|
|
setRefinedScope(proposal.refinedScope || proposal.scopeOfWork);
|
|
}
|
|
}, [proposal]);
|
|
|
|
useEffect(() => {
|
|
if (!dirty) return;
|
|
const handler = (e: BeforeUnloadEvent) => {
|
|
e.preventDefault();
|
|
};
|
|
window.addEventListener('beforeunload', handler);
|
|
return () => window.removeEventListener('beforeunload', handler);
|
|
}, [dirty]);
|
|
|
|
const blocker = useBlocker(
|
|
useCallback(
|
|
({ currentLocation, nextLocation }: { currentLocation: { pathname: string }; nextLocation: { pathname: string } }) =>
|
|
dirty && currentLocation.pathname !== nextLocation.pathname,
|
|
[dirty],
|
|
),
|
|
);
|
|
|
|
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');
|
|
},
|
|
});
|
|
|
|
const approveMutation = useMutation({
|
|
mutationFn: async () => {
|
|
if (dirty) {
|
|
await saveMutation.mutateAsync();
|
|
}
|
|
await adminApi.approveProposal(id!);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] });
|
|
setApproveDialogOpen(false);
|
|
setDirty(false);
|
|
toast.success('Proposal approved');
|
|
},
|
|
});
|
|
|
|
const sendMutation = useMutation({
|
|
mutationFn: () => adminApi.sendProposal(id!),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
|
setSendDialogOpen(false);
|
|
toast.success('Proposal marked as sent');
|
|
},
|
|
});
|
|
|
|
const reviseMutation = useMutation({
|
|
mutationFn: () => adminApi.reviseProposal(id!),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
|
setReviseDialogOpen(false);
|
|
toast.success('Revision created');
|
|
},
|
|
});
|
|
|
|
const regenerateMutation = useMutation({
|
|
mutationFn: () => adminApi.generateSuggestions(id!),
|
|
onSuccess: () => {
|
|
toast.info('AI suggestion generation started');
|
|
},
|
|
});
|
|
|
|
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 handleLineItemsChange = (updated: EditableLineItem[]) => {
|
|
setLineItems(updated);
|
|
setDirty(true);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Fix: WEB-M7 — show error state with retry button when fetch fails
|
|
if (isProposalError) {
|
|
return (
|
|
<Box sx={{ py: 4 }}>
|
|
<Alert
|
|
severity="error"
|
|
action={
|
|
<Button color="inherit" size="small" onClick={() => refetchProposal()}>
|
|
Retry
|
|
</Button>
|
|
}
|
|
>
|
|
Failed to load proposal{proposalError instanceof Error ? `: ${proposalError.message}` : '.'}
|
|
</Alert>
|
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')} sx={{ mt: 2 }}>
|
|
Back to Dashboard
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (!proposal) {
|
|
return (
|
|
<Box>
|
|
<Typography color="error">Proposal not found</Typography>
|
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')} sx={{ mt: 2 }}>
|
|
Back to Dashboard
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Fix: WEB-M10 — explicit state transition guards
|
|
const isEditable = proposal.status === 'InReview' || proposal.status === 'Revised';
|
|
const canApprove = isEditable && lineItems.length > 0;
|
|
const isApproved = proposal.status === 'Approved';
|
|
const canSend = isApproved;
|
|
const isSent = proposal.status === 'Sent';
|
|
const canRevise = isSent;
|
|
const bidTotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0);
|
|
|
|
// Tooltip messages for disabled actions
|
|
const approveDisabledReason =
|
|
!isEditable
|
|
? `Cannot approve: proposal is ${proposal.status}`
|
|
: lineItems.length === 0
|
|
? 'Add at least one line item before approving'
|
|
: bidTotal <= 0
|
|
? 'Total bid must be greater than zero'
|
|
: '';
|
|
const sendDisabledReason = !canSend ? `Cannot send: proposal must be Approved (currently ${proposal.status})` : '';
|
|
const reviseDisabledReason = !canRevise ? `Cannot revise: proposal must be Sent (currently ${proposal.status})` : '';
|
|
|
|
return (
|
|
<Box>
|
|
{/* Header */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')}>
|
|
Back
|
|
</Button>
|
|
<Typography variant="h5" sx={{ flexGrow: 1 }}>
|
|
{proposal.proposalNumber}
|
|
</Typography>
|
|
<Chip label={`Rev ${proposal.currentRevision}`} size="small" variant="outlined" />
|
|
<Chip label={proposal.status} color={STATUS_COLORS[proposal.status] || 'default'} />
|
|
</Box>
|
|
|
|
{/* Proposal Info Bar */}
|
|
<Card sx={{ mb: 2 }}>
|
|
<CardContent sx={{ py: 1.5, display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Customer</Typography>
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.customerName}</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">WO #</Typography>
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.workOrderNumber}</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Category</Typography>
|
|
<Typography variant="body2">{proposal.serviceCategory}</Typography>
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Priority</Typography>
|
|
<Chip label={proposal.priority} size="small" sx={{ ml: 0.5 }} />
|
|
</Box>
|
|
<Box>
|
|
<Typography variant="caption" color="text.secondary">Address</Typography>
|
|
<Typography variant="body2">{proposal.customerAddress}</Typography>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Three-Panel Layout */}
|
|
<Grid container spacing={2}>
|
|
{/* Left Panel: Reference */}
|
|
<Grid size={{ xs: 12, md: 3 }}>
|
|
<Card sx={{ height: '100%' }}>
|
|
<CardContent>
|
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Submission Details</Typography>
|
|
|
|
<Typography variant="caption" color="text.secondary">Submitted By</Typography>
|
|
<Typography variant="body2" sx={{ mb: 1 }}>{proposal.submittedByName || '-'}</Typography>
|
|
|
|
<Typography variant="caption" color="text.secondary">Submitted</Typography>
|
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
|
{new Date(proposal.submittedAt).toLocaleString()}
|
|
</Typography>
|
|
|
|
<Divider sx={{ my: 1.5 }} />
|
|
|
|
<Typography variant="caption" color="text.secondary">Original Scope</Typography>
|
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', mb: 1, fontSize: '0.8rem' }}>
|
|
{proposal.scopeOfWork}
|
|
</Typography>
|
|
|
|
{proposal.notes && (
|
|
<>
|
|
<Typography variant="caption" color="text.secondary">Notes</Typography>
|
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontSize: '0.8rem' }}>
|
|
{proposal.notes}
|
|
</Typography>
|
|
</>
|
|
)}
|
|
|
|
<Divider sx={{ my: 1.5 }} />
|
|
<VendorDataPanel proposalId={id!} />
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
|
|
{/* Center Panel: Editor */}
|
|
<Grid size={{ xs: 12, md: 6 }}>
|
|
<Card>
|
|
<CardContent>
|
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>Refined Scope</Typography>
|
|
<TextField
|
|
fullWidth
|
|
multiline
|
|
minRows={3}
|
|
maxRows={6}
|
|
value={refinedScope}
|
|
onChange={(e) => { setRefinedScope(e.target.value); setDirty(true); }}
|
|
disabled={!isEditable}
|
|
size="small"
|
|
sx={{ mb: 2 }}
|
|
/>
|
|
|
|
<LineItemEditor
|
|
items={lineItems}
|
|
onChange={handleLineItemsChange}
|
|
disabled={!isEditable}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
|
|
{/* Right Panel: Similar Proposals */}
|
|
<Grid size={{ xs: 12, md: 3 }}>
|
|
<Card sx={{ height: '100%' }}>
|
|
<CardContent>
|
|
<SimilarProposalsPanel
|
|
proposalId={id!}
|
|
onPullLineItem={(item) => {
|
|
const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0;
|
|
handleLineItemsChange([
|
|
...lineItems,
|
|
{ ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' },
|
|
]);
|
|
}}
|
|
disabled={!isEditable}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
{/* Action Bar */}
|
|
<Card sx={{ mt: 2, position: 'sticky', bottom: 16, zIndex: 10 }}>
|
|
<CardContent sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
{dirty && (
|
|
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert>
|
|
)}
|
|
{!dirty && <Box sx={{ flexGrow: 1 }} />}
|
|
|
|
{/* Fix: WEB-M10 — state transition guards with tooltip explanations */}
|
|
{isEditable && (
|
|
<>
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={<RefreshIcon />}
|
|
onClick={() => regenerateMutation.mutate()}
|
|
disabled={regenerateMutation.isPending}
|
|
>
|
|
Regenerate
|
|
</Button>
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<SaveIcon />}
|
|
onClick={() => saveMutation.mutate()}
|
|
disabled={saveMutation.isPending || !dirty}
|
|
>
|
|
Save
|
|
</Button>
|
|
<Tooltip title={approveDisabledReason} disableHoverListener={!approveDisabledReason}>
|
|
<span>
|
|
<Button
|
|
variant="contained"
|
|
color="success"
|
|
startIcon={<CheckCircleIcon />}
|
|
onClick={() => setApproveDialogOpen(true)}
|
|
disabled={!canApprove || lineItems.length === 0 || bidTotal <= 0}
|
|
>
|
|
Approve
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
|
|
{isApproved && (
|
|
<>
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<PictureAsPdfIcon />}
|
|
onClick={() => pdfMutation.mutate()}
|
|
disabled={pdfMutation.isPending}
|
|
>
|
|
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
|
|
</Button>
|
|
<Tooltip title={sendDisabledReason} disableHoverListener={!sendDisabledReason}>
|
|
<span>
|
|
<Button
|
|
variant="contained"
|
|
color="success"
|
|
startIcon={<SendIcon />}
|
|
onClick={() => setSendDialogOpen(true)}
|
|
disabled={!canSend || sendMutation.isPending}
|
|
>
|
|
Mark as Sent
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
</>
|
|
)}
|
|
|
|
{isSent && (
|
|
<Tooltip title={reviseDisabledReason} disableHoverListener={!reviseDisabledReason}>
|
|
<span>
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={<HistoryIcon />}
|
|
onClick={() => setReviseDialogOpen(true)}
|
|
disabled={!canRevise || reviseMutation.isPending}
|
|
>
|
|
Create Revision
|
|
</Button>
|
|
</span>
|
|
</Tooltip>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Unsaved Changes Navigation Guard */}
|
|
<Dialog open={blocker.state === 'blocked'} onClose={() => blocker.reset?.()}>
|
|
<DialogTitle>Unsaved Changes</DialogTitle>
|
|
<DialogContent>
|
|
<Typography>You have unsaved changes. Are you sure you want to leave?</Typography>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => blocker.reset?.()}>Stay</Button>
|
|
<Button variant="contained" color="error" onClick={() => blocker.proceed?.()}>
|
|
Discard & Leave
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Approve Confirmation Dialog */}
|
|
<Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}>
|
|
<DialogTitle>Approve Proposal</DialogTitle>
|
|
<DialogContent>
|
|
<Typography gutterBottom>
|
|
Approve <strong>{proposal.proposalNumber}</strong> for {proposal.customerName}?
|
|
</Typography>
|
|
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
|
|
<Typography variant="body2">Line Items: {lineItems.length}</Typography>
|
|
<Typography variant="body2">
|
|
Total Bid: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(bidTotal)}
|
|
</Typography>
|
|
{proposal.vendorTotalCost != null && (
|
|
<Typography variant="body2">
|
|
Vendor Cost: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(proposal.vendorTotalCost)}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setApproveDialogOpen(false)}>Cancel</Button>
|
|
<Button
|
|
variant="contained"
|
|
color="success"
|
|
onClick={() => approveMutation.mutate()}
|
|
disabled={approveMutation.isPending}
|
|
>
|
|
{approveMutation.isPending ? 'Approving...' : 'Confirm Approve'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Mark as Sent Confirmation Dialog */}
|
|
<Dialog open={sendDialogOpen} onClose={() => setSendDialogOpen(false)}>
|
|
<DialogTitle>Mark as Sent</DialogTitle>
|
|
<DialogContent>
|
|
<Typography>
|
|
Mark <strong>{proposal.proposalNumber}</strong> as sent to {proposal.customerName}?
|
|
This action cannot be undone.
|
|
</Typography>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setSendDialogOpen(false)}>Cancel</Button>
|
|
<Button
|
|
variant="contained"
|
|
color="success"
|
|
onClick={() => sendMutation.mutate()}
|
|
disabled={sendMutation.isPending}
|
|
>
|
|
{sendMutation.isPending ? 'Sending...' : 'Confirm Send'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Create Revision Confirmation Dialog */}
|
|
<Dialog open={reviseDialogOpen} onClose={() => setReviseDialogOpen(false)}>
|
|
<DialogTitle>Create Revision</DialogTitle>
|
|
<DialogContent>
|
|
<Typography>
|
|
Create a new revision of <strong>{proposal.proposalNumber}</strong>?
|
|
The current version will be marked as revised and a new editable copy will be created.
|
|
</Typography>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setReviseDialogOpen(false)}>Cancel</Button>
|
|
<Button
|
|
variant="contained"
|
|
onClick={() => reviseMutation.mutate()}
|
|
disabled={reviseMutation.isPending}
|
|
>
|
|
{reviseMutation.isPending ? 'Creating...' : 'Confirm Revision'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
}
|