proposal-system/web/src/pages/admin/workspace/AdminWorkspace.tsx
Adam Moussa 51ca6df403 fix(web): WEB-M3, M4, M8, M9, M11 — scope validation, category alignment, dashboard errors, line item skeleton, return-to-review
- WEB-M3: Add minimum length validation (10 chars) on scope of work field
  with inline MUI error message
- WEB-M4: Add 'Other' to shared ServiceCategory contract to align with
  API enum (already present in frontend and backend)
- WEB-M8: Show error alert with retry button instead of misleading zeros
  when dashboard stats fetch fails (both dispatcher and admin dashboards)
- WEB-M9: Add MUI Skeleton loading state for line items in admin workspace
- WEB-M11: Wire 'Return to Review' button on approved proposals — backend
  supports Approved->InReview transition, API client already had the method
2026-05-27 18:18:44 -04:00

664 lines
24 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,
Link,
Skeleton,
} 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 NavigateNextIcon from '@mui/icons-material/NavigateNext';
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 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 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';
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);
// Fix: WEB-M11 — add dialog state for Return to Review action
const [returnToReviewDialogOpen, setReturnToReviewDialogOpen] = 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,
});
// 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: similarProposals } = useQuery({
queryKey: ['similarProposals', id],
queryFn: () => adminApi.getSimilar(id!),
enabled: !!id,
});
const hasSimilar = similarProposals && similarProposals.length > 0;
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');
},
onError: (error: Error) => {
toast.error(`Save failed: ${error.message}`);
},
});
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');
},
onError: (error: Error) => {
toast.error(`Approval failed: ${error.message}`);
},
});
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 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 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}`);
},
});
// 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 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: 1, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexGrow: 1 }}>
<Link
component="button"
underline="hover"
onClick={() => navigate('/admin')}
sx={{ fontSize: 14, color: '#0B5A73', cursor: 'pointer' }}
>
Admin Queue
</Link>
<NavigateNextIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography sx={{ fontSize: 26, fontWeight: 800 }}>
{proposal.proposalNumber}
</Typography>
</Box>
<Chip label={`Rev ${proposal.currentRevision}`} size="small" variant="outlined" />
<Chip label={proposal.status} sx={STATUS_CHIP_STYLES[proposal.status]} />
</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={{ position: 'sticky', top: 88, alignSelf: 'flex-start', maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
<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: hasSimilar ? 6 : 9 }}>
<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 }}
/>
{/* Fix: WEB-M9 — show loading skeleton while line items are being fetched */}
{lineItemsLoading ? (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Skeleton variant="text" width={120} />
<Skeleton variant="rectangular" width={120} height={32} sx={{ borderRadius: 1 }} />
</Box>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} variant="rectangular" height={48} sx={{ mb: 0.5, borderRadius: 0.5 }} />
))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Skeleton variant="text" width={180} />
</Box>
</Box>
) : (
<LineItemEditor
items={lineItems}
onChange={handleLineItemsChange}
disabled={!isEditable}
/>
)}
</CardContent>
</Card>
</Grid>
{/* Right Panel: Similar Proposals (hidden when empty) */}
{hasSimilar && (
<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: 0, zIndex: 10, bgcolor: 'rgba(255,255,255,0.96)', backdropFilter: 'blur(8px)', borderTop: '1px solid #D6E0EA', boxShadow: '0 -4px 12px rgba(15,23,42,0.06)', border: 'none' }}>
<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>
<Tooltip
title={!dirty ? 'No unsaved changes' : ''}
disableHoverListener={dirty}
>
<span>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !dirty}
>
Save
</Button>
</span>
</Tooltip>
<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 && (
<>
{/* Fix: WEB-M11 — wire Return to Review button for approved proposals */}
<Button
variant="outlined"
startIcon={<UndoIcon />}
onClick={() => setReturnToReviewDialogOpen(true)}
disabled={returnToReviewMutation.isPending}
>
Return to Review
</Button>
<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>
{/* Fix: WEB-M11 — Return to Review Confirmation Dialog */}
<Dialog open={returnToReviewDialogOpen} onClose={() => setReturnToReviewDialogOpen(false)}>
<DialogTitle>Return to Review</DialogTitle>
<DialogContent>
<Typography>
Return <strong>{proposal.proposalNumber}</strong> to In Review status?
This will clear the approval and allow further edits to line items and scope.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setReturnToReviewDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
color="warning"
onClick={() => returnToReviewMutation.mutate()}
disabled={returnToReviewMutation.isPending}
>
{returnToReviewMutation.isPending ? 'Returning...' : 'Confirm Return'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}