Add revision dropdown, editable WO#, and fix Revised proposal approval

- Fix: ApproveAsync now accepts both InReview and Revised proposals
- Revision dropdown in header: navigate between revisions, download PDF per rev
- Work Order Number editable in admin workspace (same pattern as PO#)
- Added WorkOrderNumber to UpdateProposalRequest DTO and service
- Info bar reordered: Customer, Site, WO#, PO#, Category, Priority
- Uniform font sizing across info bar (0.75rem labels, 0.875rem values)
- Typed getHistory API to return ProposalDetail[]
- Download PDF button in action bar for Sent/Revised proposals
This commit is contained in:
Adam Moussa 2026-05-22 16:40:39 -04:00
parent cab97cbb1b
commit 6de4bafec4
4 changed files with 351 additions and 53 deletions

View file

@ -4,6 +4,7 @@ namespace ProposalSystem.Application.DTOs;
public record CreateProposalRequest( public record CreateProposalRequest(
string WorkOrderNumber, string WorkOrderNumber,
string? PoNumber,
string CustomerName, string CustomerName,
string CustomerAddress, string CustomerAddress,
string ScopeOfWork, string ScopeOfWork,
@ -15,6 +16,8 @@ public record CreateProposalRequest(
public record UpdateProposalRequest( public record UpdateProposalRequest(
string? RefinedScope, string? RefinedScope,
string? Notes, string? Notes,
string? PoNumber,
string? WorkOrderNumber,
Guid? AssignedAdminId, Guid? AssignedAdminId,
ProposalStatus? Status ProposalStatus? Status
); );
@ -23,6 +26,7 @@ public record ProposalResponse(
Guid Id, Guid Id,
string ProposalNumber, string ProposalNumber,
string WorkOrderNumber, string WorkOrderNumber,
string? PoNumber,
string CustomerName, string CustomerName,
string CustomerAddress, string CustomerAddress,
string ScopeOfWork, string ScopeOfWork,

View file

@ -40,6 +40,7 @@ public class ProposalService : IProposalService
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ProposalNumber = proposalNumber, ProposalNumber = proposalNumber,
WorkOrderNumber = request.WorkOrderNumber, WorkOrderNumber = request.WorkOrderNumber,
PoNumber = request.PoNumber,
CustomerName = request.CustomerName, CustomerName = request.CustomerName,
CustomerAddress = request.CustomerAddress, CustomerAddress = request.CustomerAddress,
ScopeOfWork = request.ScopeOfWork, ScopeOfWork = request.ScopeOfWork,
@ -165,6 +166,12 @@ public class ProposalService : IProposalService
if (request.Notes != null) if (request.Notes != null)
proposal.Notes = request.Notes; proposal.Notes = request.Notes;
if (request.PoNumber != null)
proposal.PoNumber = request.PoNumber;
if (request.WorkOrderNumber != null)
proposal.WorkOrderNumber = request.WorkOrderNumber;
if (request.AssignedAdminId.HasValue) if (request.AssignedAdminId.HasValue)
proposal.AssignedAdminId = request.AssignedAdminId.Value; proposal.AssignedAdminId = request.AssignedAdminId.Value;
@ -192,7 +199,7 @@ public class ProposalService : IProposalService
if (proposal.Status == ProposalStatus.Approved) if (proposal.Status == ProposalStatus.Approved)
return MapToResponse(proposal); return MapToResponse(proposal);
if (proposal.Status != ProposalStatus.InReview) if (proposal.Status != ProposalStatus.InReview && proposal.Status != ProposalStatus.Revised)
throw new InvalidOperationException("Only proposals in review can be approved"); throw new InvalidOperationException("Only proposals in review can be approved");
if (!proposal.LineItems.Any() || proposal.LineItems.All(li => li.TotalPrice <= 0)) if (!proposal.LineItems.Any() || proposal.LineItems.All(li => li.TotalPrice <= 0))
@ -210,6 +217,31 @@ public class ProposalService : IProposalService
return MapToResponse(proposal); return MapToResponse(proposal);
} }
public async Task<ProposalResponse> ReturnToReviewAsync(Guid id, CancellationToken ct = default)
{
var proposal = await _db.Proposals
.Include(p => p.SubmittedBy)
.Include(p => p.ApprovedBy)
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.InReview)
return MapToResponse(proposal);
if (proposal.Status != ProposalStatus.Approved)
throw new InvalidOperationException("Only approved proposals can be returned to review");
proposal.Status = ProposalStatus.InReview;
proposal.ApprovedById = null;
proposal.ApprovedAt = null;
proposal.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.ReturnToReview, id, null, ct);
return MapToResponse(proposal);
}
public async Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default) public async Task<ProposalResponse> MarkSentAsync(Guid id, CancellationToken ct = default)
{ {
var proposal = await _db.Proposals var proposal = await _db.Proposals
@ -259,6 +291,7 @@ public class ProposalService : IProposalService
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
ProposalNumber = $"{proposal.ProposalNumber}-R{proposal.CurrentRevision + 1}", ProposalNumber = $"{proposal.ProposalNumber}-R{proposal.CurrentRevision + 1}",
WorkOrderNumber = proposal.WorkOrderNumber, WorkOrderNumber = proposal.WorkOrderNumber,
PoNumber = proposal.PoNumber,
CustomerName = proposal.CustomerName, CustomerName = proposal.CustomerName,
CustomerAddress = proposal.CustomerAddress, CustomerAddress = proposal.CustomerAddress,
ScopeOfWork = proposal.ScopeOfWork, ScopeOfWork = proposal.ScopeOfWork,
@ -366,6 +399,7 @@ public class ProposalService : IProposalService
p.Id, p.Id,
p.ProposalNumber, p.ProposalNumber,
p.WorkOrderNumber, p.WorkOrderNumber,
p.PoNumber,
p.CustomerName, p.CustomerName,
p.CustomerAddress, p.CustomerAddress,
p.ScopeOfWork, p.ScopeOfWork,

View file

@ -1,4 +1,5 @@
import apiClient from './client'; import apiClient from './client';
import { type ProposalDetail } from './proposals';
export interface DashboardStats { export interface DashboardStats {
pendingCount: number; pendingCount: number;
@ -10,6 +11,8 @@ export interface DashboardStats {
export interface UpdateProposalRequest { export interface UpdateProposalRequest {
refinedScope?: string; refinedScope?: string;
notes?: string; notes?: string;
poNumber?: string;
workOrderNumber?: string;
assignedAdminId?: string; assignedAdminId?: string;
} }
@ -38,6 +41,10 @@ export const adminApi = {
await apiClient.post(`/proposals/${id}/approve`); await apiClient.post(`/proposals/${id}/approve`);
}, },
returnToReview: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/return-to-review`);
},
sendProposal: async (id: string): Promise<void> => { sendProposal: async (id: string): Promise<void> => {
await apiClient.post(`/proposals/${id}/send`); await apiClient.post(`/proposals/${id}/send`);
}, },
@ -46,7 +53,7 @@ export const adminApi = {
await apiClient.post(`/proposals/${id}/revise`); await apiClient.post(`/proposals/${id}/revise`);
}, },
getHistory: async (id: string): Promise<unknown[]> => { getHistory: async (id: string): Promise<ProposalDetail[]> => {
const res = await apiClient.get(`/proposals/${id}/history`); const res = await apiClient.get(`/proposals/${id}/history`);
return res.data; return res.data;
}, },
@ -65,9 +72,15 @@ export const adminApi = {
return res.data; return res.data;
}, },
getPdf: async (id: string): Promise<{ downloadUrl: string; expiresAt: string } | null> => { getPdf: async (id: string, regenerate = false): Promise<{ downloadUrl: string } | null> => {
const res = await apiClient.get(`/proposals/${id}/pdf`); const params = regenerate ? '?regenerate=true' : '';
const res = await apiClient.get(`/proposals/${id}/pdf${params}`, {
responseType: 'blob',
validateStatus: (status) => status < 500,
});
if (res.status === 202) return null; if (res.status === 202) return null;
return res.data; const blob = res.data as Blob;
const downloadUrl = URL.createObjectURL(blob);
return { downloadUrl };
}, },
}; };

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, useBlocker } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { import {
Box, Box,
@ -17,6 +17,9 @@ import {
DialogActions, DialogActions,
CircularProgress, CircularProgress,
Alert, Alert,
Menu,
MenuItem,
IconButton,
} from '@mui/material'; } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save'; import SaveIcon from '@mui/icons-material/Save';
@ -25,16 +28,49 @@ import SendIcon from '@mui/icons-material/Send';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import HistoryIcon from '@mui/icons-material/History'; import HistoryIcon from '@mui/icons-material/History';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import DownloadIcon from '@mui/icons-material/Download';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
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 { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems'; import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems';
import { adminApi } from '../../../lib/api/admin'; import { adminApi, type AuditEntry } from '../../../lib/api/admin';
import { queryClient } from '../../../lib/queryClient'; import { queryClient } from '../../../lib/queryClient';
import { QUERY_KEYS } from '../../../constants/queryKeys'; import { QUERY_KEYS } from '../../../constants/queryKeys';
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_COLORS } from '../../../constants'; import { STATUS_COLORS, STATUS_LABELS, PRIORITY_LABELS } from '../../../constants';
const ACTION_LABELS: Record<string, string> = {
Submit: 'Submitted',
Edit: 'Edited',
Approve: 'Approved',
ReturnToReview: 'Returned to Review',
MarkSent: 'Marked Sent',
CreateRevision: 'Revision Created',
EditLineItem: 'Line Items Updated',
EditScope: 'Scope Edited',
Regenerate: 'Regenerated',
GeneratePDF: 'PDF Generated',
Download: 'Downloaded',
AssignAdmin: 'Admin Assigned',
UpdateRole: 'Role Updated',
Revise: 'Revised',
};
function formatAction(action: string): string {
return ACTION_LABELS[action] || action.replace(/([A-Z])/g, ' $1').trim();
}
function formatDetails(details: string): string {
try {
const parsed = JSON.parse(details);
return parsed.message || details;
} catch {
return details;
}
}
export default function AdminWorkspace() { export default function AdminWorkspace() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -42,10 +78,15 @@ export default function AdminWorkspace() {
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]); const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
const [refinedScope, setRefinedScope] = useState(''); const [refinedScope, setRefinedScope] = useState('');
const [workOrderNumber, setWorkOrderNumber] = useState('');
const [poNumber, setPoNumber] = useState('');
const [approveDialogOpen, setApproveDialogOpen] = useState(false); const [approveDialogOpen, setApproveDialogOpen] = useState(false);
const [sendDialogOpen, setSendDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [reviseDialogOpen, setReviseDialogOpen] = useState(false); const [reviseDialogOpen, setReviseDialogOpen] = useState(false);
const [returnToReviewDialogOpen, setReturnToReviewDialogOpen] = useState(false);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [revMenuAnchor, setRevMenuAnchor] = useState<null | HTMLElement>(null);
const [downloadingRevId, setDownloadingRevId] = useState<string | null>(null);
const { data: proposal, isLoading } = useQuery<ProposalDetail>({ const { data: proposal, isLoading } = useQuery<ProposalDetail>({
queryKey: [QUERY_KEYS.proposals, id], queryKey: [QUERY_KEYS.proposals, id],
@ -59,6 +100,18 @@ export default function AdminWorkspace() {
enabled: !!id, enabled: !!id,
}); });
const { data: auditTrail } = useQuery<AuditEntry[]>({
queryKey: ['audit', id],
queryFn: () => adminApi.getAudit(id!),
enabled: !!id,
});
const { data: revisionHistory } = useQuery<ProposalDetail[]>({
queryKey: [QUERY_KEYS.proposals, id, 'history'],
queryFn: () => adminApi.getHistory(id!),
enabled: !!id,
});
useEffect(() => { useEffect(() => {
if (fetchedLineItems) { if (fetchedLineItems) {
setLineItems( setLineItems(
@ -80,9 +133,14 @@ export default function AdminWorkspace() {
useEffect(() => { useEffect(() => {
if (proposal) { if (proposal) {
setRefinedScope(proposal.refinedScope || proposal.scopeOfWork); setRefinedScope(proposal.refinedScope || proposal.scopeOfWork);
setWorkOrderNumber(proposal.workOrderNumber || '');
setPoNumber(proposal.poNumber || '');
} }
}, [proposal]); }, [proposal]);
const dirtyRef = useRef(dirty);
dirtyRef.current = dirty;
useEffect(() => { useEffect(() => {
if (!dirty) return; if (!dirty) return;
const handler = (e: BeforeUnloadEvent) => { const handler = (e: BeforeUnloadEvent) => {
@ -92,17 +150,9 @@ export default function AdminWorkspace() {
return () => window.removeEventListener('beforeunload', handler); return () => window.removeEventListener('beforeunload', handler);
}, [dirty]); }, [dirty]);
const blocker = useBlocker(
useCallback(
({ currentLocation, nextLocation }: { currentLocation: { pathname: string }; nextLocation: { pathname: string } }) =>
dirty && currentLocation.pathname !== nextLocation.pathname,
[dirty],
),
);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
await adminApi.updateProposal(id!, { refinedScope }); await adminApi.updateProposal(id!, { refinedScope, workOrderNumber, poNumber });
await lineItemsApi.bulkUpdate( await lineItemsApi.bulkUpdate(
id!, id!,
lineItems.map((li) => ({ lineItems.map((li) => ({
@ -121,6 +171,7 @@ export default function AdminWorkspace() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: ['audit', id] });
setDirty(false); setDirty(false);
toast.success('Changes saved'); toast.success('Changes saved');
}, },
@ -136,6 +187,7 @@ export default function AdminWorkspace() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] });
queryClient.invalidateQueries({ queryKey: ['audit', id] });
setApproveDialogOpen(false); setApproveDialogOpen(false);
setDirty(false); setDirty(false);
toast.success('Proposal approved'); toast.success('Proposal approved');
@ -146,6 +198,7 @@ export default function AdminWorkspace() {
mutationFn: () => adminApi.sendProposal(id!), mutationFn: () => adminApi.sendProposal(id!),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: ['audit', id] });
setSendDialogOpen(false); setSendDialogOpen(false);
toast.success('Proposal marked as sent'); toast.success('Proposal marked as sent');
}, },
@ -155,11 +208,22 @@ export default function AdminWorkspace() {
mutationFn: () => adminApi.reviseProposal(id!), mutationFn: () => adminApi.reviseProposal(id!),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: ['audit', id] });
setReviseDialogOpen(false); setReviseDialogOpen(false);
toast.success('Revision created'); toast.success('Revision created');
}, },
}); });
const returnToReviewMutation = useMutation({
mutationFn: () => adminApi.returnToReview(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: ['audit', id] });
setReturnToReviewDialogOpen(false);
toast.success('Proposal returned to review');
},
});
const regenerateMutation = useMutation({ const regenerateMutation = useMutation({
mutationFn: () => adminApi.generateSuggestions(id!), mutationFn: () => adminApi.generateSuggestions(id!),
onSuccess: () => { onSuccess: () => {
@ -168,11 +232,12 @@ export default function AdminWorkspace() {
}); });
const pdfMutation = useMutation({ const pdfMutation = useMutation({
mutationFn: () => adminApi.getPdf(id!), mutationFn: (regenerate?: boolean) => adminApi.getPdf(id!, regenerate ?? false),
onSuccess: (data) => { onSuccess: (data) => {
if (data?.downloadUrl) { if (data?.downloadUrl) {
window.open(data.downloadUrl, '_blank'); window.open(data.downloadUrl, '_blank');
toast.success('PDF ready — downloading'); queryClient.invalidateQueries({ queryKey: ['audit', id] });
toast.success('PDF ready');
} else { } else {
toast.info('PDF generation queued — try again in a few seconds'); toast.info('PDF generation queued — try again in a few seconds');
} }
@ -187,6 +252,29 @@ export default function AdminWorkspace() {
setDirty(true); setDirty(true);
}; };
const DOWNLOADABLE_STATUSES = ['Approved', 'Sent', 'Revised'];
const handleDownloadPdf = async (revId: string, proposalNumber: string, revision: number) => {
setDownloadingRevId(revId);
try {
const result = await proposalsApi.getPdf(revId);
if (result) {
const a = document.createElement('a');
a.href = result.downloadUrl;
a.download = `${proposalNumber}-rev${revision}.pdf`;
a.click();
URL.revokeObjectURL(result.downloadUrl);
toast.success('PDF downloaded');
} else {
toast.info('PDF not yet generated for this revision');
}
} catch {
toast.error('Failed to download PDF');
} finally {
setDownloadingRevId(null);
}
};
if (isLoading) { if (isLoading) {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
@ -215,38 +303,116 @@ export default function AdminWorkspace() {
<Box> <Box>
{/* Header */} {/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')}> <Button startIcon={<ArrowBackIcon />} onClick={() => {
if (dirty && !window.confirm('You have unsaved changes. Are you sure you want to leave?')) return;
navigate('/admin');
}}>
Back Back
</Button> </Button>
<Typography variant="h5" sx={{ flexGrow: 1 }}> <Typography variant="h5" sx={{ flexGrow: 1 }}>
{proposal.proposalNumber} {proposal.proposalNumber}
</Typography> </Typography>
<Chip label={`Rev ${proposal.currentRevision}`} size="small" variant="outlined" /> <Chip
<Chip label={proposal.status} color={STATUS_COLORS[proposal.status] || 'default'} /> label={`Rev ${proposal.currentRevision}`}
size="small"
variant="outlined"
onClick={(e) => setRevMenuAnchor(e.currentTarget)}
onDelete={(e) => setRevMenuAnchor(e.currentTarget as HTMLElement)}
deleteIcon={<ArrowDropDownIcon />}
sx={{ cursor: 'pointer' }}
/>
<Menu
anchorEl={revMenuAnchor}
open={Boolean(revMenuAnchor)}
onClose={() => setRevMenuAnchor(null)}
>
{(revisionHistory ?? [])
.sort((a, b) => a.currentRevision - b.currentRevision)
.map((rev) => (
<MenuItem
key={rev.id}
selected={rev.id === id}
dense
onClick={() => {
setRevMenuAnchor(null);
if (rev.id !== id) {
if (dirty && !window.confirm('You have unsaved changes. Are you sure you want to switch revisions?')) return;
navigate(`/admin/proposals/${rev.id}`);
}
}}
sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, minWidth: 160 }}
>
<Box>
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 600 }}>Rev {rev.currentRevision}</Typography>
<Typography sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>{STATUS_LABELS[rev.status] || rev.status}</Typography>
</Box>
{DOWNLOADABLE_STATUSES.includes(rev.status) && (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setRevMenuAnchor(null);
handleDownloadPdf(rev.id, rev.proposalNumber, rev.currentRevision);
}}
disabled={downloadingRevId === rev.id}
>
<DownloadIcon sx={{ fontSize: '1rem' }} />
</IconButton>
)}
</MenuItem>
))}
</Menu>
<Chip label={STATUS_LABELS[proposal.status] || proposal.status} color={STATUS_COLORS[proposal.status] || 'default'} />
</Box> </Box>
{/* Proposal Info Bar */} {/* Proposal Info Bar */}
<Card sx={{ mb: 2 }}> <Card sx={{ mb: 2 }}>
<CardContent sx={{ py: 1.5, display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}> <CardContent sx={{ py: 1.5, display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'flex-start' }}>
<Box> <Box>
<Typography variant="caption" color="text.secondary">Customer</Typography> <Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>Customer</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.customerName}</Typography> <Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{proposal.customerName}</Typography>
</Box> </Box>
<Box> <Box>
<Typography variant="caption" color="text.secondary">WO #</Typography> <Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>Site</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.workOrderNumber}</Typography> <Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{proposal.customerAddress}</Typography>
</Box> </Box>
<Box> <Box>
<Typography variant="caption" color="text.secondary">Category</Typography> <Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>WO #</Typography>
<Typography variant="body2">{proposal.serviceCategory}</Typography> {isEditable ? (
<TextField
size="small"
variant="standard"
placeholder="—"
value={workOrderNumber}
onChange={(e) => { setWorkOrderNumber(e.target.value); setDirty(true); }}
sx={{ width: 140, display: 'block', '& input': { fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600, p: 0 }, '& .MuiInput-root': { mt: 0 } }}
/>
) : (
<Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{workOrderNumber || '—'}</Typography>
)}
</Box> </Box>
<Box> <Box>
<Typography variant="caption" color="text.secondary">Priority</Typography> <Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>PO #</Typography>
<Chip label={proposal.priority} size="small" sx={{ ml: 0.5 }} /> {isEditable ? (
<TextField
size="small"
variant="standard"
placeholder="—"
value={poNumber}
onChange={(e) => { setPoNumber(e.target.value); setDirty(true); }}
sx={{ width: 120, display: 'block', '& input': { fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600, p: 0 }, '& .MuiInput-root': { mt: 0 } }}
/>
) : (
<Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{poNumber || '—'}</Typography>
)}
</Box> </Box>
<Box> <Box>
<Typography variant="caption" color="text.secondary">Address</Typography> <Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>Category</Typography>
<Typography variant="body2">{proposal.customerAddress}</Typography> <Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{proposal.serviceCategory}</Typography>
</Box>
<Box>
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>Priority</Typography>
<Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{PRIORITY_LABELS[proposal.priority] || proposal.priority}</Typography>
</Box> </Box>
</CardContent> </CardContent>
</Card> </Card>
@ -375,10 +541,19 @@ export default function AdminWorkspace() {
{isApproved && ( {isApproved && (
<> <>
<Button
variant="outlined"
color="warning"
startIcon={<UndoIcon />}
onClick={() => setReturnToReviewDialogOpen(true)}
disabled={returnToReviewMutation.isPending}
>
Return to Review
</Button>
<Button <Button
variant="contained" variant="contained"
startIcon={<PictureAsPdfIcon />} startIcon={<PictureAsPdfIcon />}
onClick={() => pdfMutation.mutate()} onClick={() => pdfMutation.mutate(false)}
disabled={pdfMutation.isPending} disabled={pdfMutation.isPending}
> >
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'} {pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
@ -396,32 +571,39 @@ export default function AdminWorkspace() {
)} )}
{isSent && ( {isSent && (
<>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => handleDownloadPdf(id!, proposal.proposalNumber, proposal.currentRevision)}
disabled={downloadingRevId === id}
>
Download PDF
</Button>
<Button
variant="outlined"
startIcon={<HistoryIcon />}
onClick={() => setReviseDialogOpen(true)}
disabled={reviseMutation.isPending}
>
Create Revision
</Button>
</>
)}
{proposal.status === 'Revised' && (
<Button <Button
variant="outlined" variant="outlined"
startIcon={<HistoryIcon />} startIcon={<DownloadIcon />}
onClick={() => setReviseDialogOpen(true)} onClick={() => handleDownloadPdf(id!, proposal.proposalNumber, proposal.currentRevision)}
disabled={reviseMutation.isPending} disabled={downloadingRevId === id}
> >
Create Revision Download PDF
</Button> </Button>
)} )}
</CardContent> </CardContent>
</Card> </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 */} {/* Approve Confirmation Dialog */}
<Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}> <Dialog open={approveDialogOpen} onClose={() => setApproveDialogOpen(false)}>
<DialogTitle>Approve Proposal</DialogTitle> <DialogTitle>Approve Proposal</DialogTitle>
@ -496,6 +678,71 @@ export default function AdminWorkspace() {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Return to Review Confirmation Dialog */}
<Dialog open={returnToReviewDialogOpen} onClose={() => setReturnToReviewDialogOpen(false)}>
<DialogTitle>Return to Review</DialogTitle>
<DialogContent>
<Typography>
Return <strong>{proposal.proposalNumber}</strong> to review?
This will clear the approval and allow further editing.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setReturnToReviewDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
color="warning"
onClick={() => returnToReviewMutation.mutate()}
disabled={returnToReviewMutation.isPending}
>
{returnToReviewMutation.isPending ? 'Returning...' : 'Confirm'}
</Button>
</DialogActions>
</Dialog>
{/* Audit Trail */}
{auditTrail && auditTrail.length > 0 && (
<Card sx={{ mt: 2 }}>
<CardContent sx={{ py: 1.5 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Activity Log</Typography>
<Box sx={{ maxHeight: 240, overflow: 'auto' }}>
<Box
component="table"
sx={{
width: '100%',
borderCollapse: 'collapse',
'& tr:hover': { bgcolor: 'action.hover' },
'& td': { padding: '6px 8px', border: 0 },
}}
>
<tbody>
{auditTrail.map((entry) => (
<tr key={entry.id}>
<td style={{ whiteSpace: 'nowrap', width: 155, paddingLeft: 0 }}>
<Typography variant="caption" color="text.secondary">
{new Date(entry.timestamp).toLocaleString()}
</Typography>
</td>
<td style={{ width: 150 }}>
<Chip label={formatAction(entry.action)} size="small" variant="outlined" />
</td>
<td style={{ whiteSpace: 'nowrap', width: 120 }}>
<Typography variant="caption">{entry.userName}</Typography>
</td>
<td style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 0 }}>
<Typography variant="caption" color="text.secondary">
{entry.details ? formatDetails(entry.details) : ''}
</Typography>
</td>
</tr>
))}
</tbody>
</Box>
</Box>
</CardContent>
</Card>
)}
</Box> </Box>
); );
} }