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(
string WorkOrderNumber,
string? PoNumber,
string CustomerName,
string CustomerAddress,
string ScopeOfWork,
@ -15,6 +16,8 @@ public record CreateProposalRequest(
public record UpdateProposalRequest(
string? RefinedScope,
string? Notes,
string? PoNumber,
string? WorkOrderNumber,
Guid? AssignedAdminId,
ProposalStatus? Status
);
@ -23,6 +26,7 @@ public record ProposalResponse(
Guid Id,
string ProposalNumber,
string WorkOrderNumber,
string? PoNumber,
string CustomerName,
string CustomerAddress,
string ScopeOfWork,

View file

@ -40,6 +40,7 @@ public class ProposalService : IProposalService
Id = Guid.NewGuid(),
ProposalNumber = proposalNumber,
WorkOrderNumber = request.WorkOrderNumber,
PoNumber = request.PoNumber,
CustomerName = request.CustomerName,
CustomerAddress = request.CustomerAddress,
ScopeOfWork = request.ScopeOfWork,
@ -165,6 +166,12 @@ public class ProposalService : IProposalService
if (request.Notes != null)
proposal.Notes = request.Notes;
if (request.PoNumber != null)
proposal.PoNumber = request.PoNumber;
if (request.WorkOrderNumber != null)
proposal.WorkOrderNumber = request.WorkOrderNumber;
if (request.AssignedAdminId.HasValue)
proposal.AssignedAdminId = request.AssignedAdminId.Value;
@ -192,7 +199,7 @@ public class ProposalService : IProposalService
if (proposal.Status == ProposalStatus.Approved)
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");
if (!proposal.LineItems.Any() || proposal.LineItems.All(li => li.TotalPrice <= 0))
@ -210,6 +217,31 @@ public class ProposalService : IProposalService
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)
{
var proposal = await _db.Proposals
@ -259,6 +291,7 @@ public class ProposalService : IProposalService
Id = Guid.NewGuid(),
ProposalNumber = $"{proposal.ProposalNumber}-R{proposal.CurrentRevision + 1}",
WorkOrderNumber = proposal.WorkOrderNumber,
PoNumber = proposal.PoNumber,
CustomerName = proposal.CustomerName,
CustomerAddress = proposal.CustomerAddress,
ScopeOfWork = proposal.ScopeOfWork,
@ -366,6 +399,7 @@ public class ProposalService : IProposalService
p.Id,
p.ProposalNumber,
p.WorkOrderNumber,
p.PoNumber,
p.CustomerName,
p.CustomerAddress,
p.ScopeOfWork,

View file

@ -1,4 +1,5 @@
import apiClient from './client';
import { type ProposalDetail } from './proposals';
export interface DashboardStats {
pendingCount: number;
@ -10,6 +11,8 @@ export interface DashboardStats {
export interface UpdateProposalRequest {
refinedScope?: string;
notes?: string;
poNumber?: string;
workOrderNumber?: string;
assignedAdminId?: string;
}
@ -38,6 +41,10 @@ export const adminApi = {
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> => {
await apiClient.post(`/proposals/${id}/send`);
},
@ -46,7 +53,7 @@ export const adminApi = {
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`);
return res.data;
},
@ -65,9 +72,15 @@ export const adminApi = {
return res.data;
},
getPdf: async (id: string): Promise<{ downloadUrl: string; expiresAt: string } | null> => {
const res = await apiClient.get(`/proposals/${id}/pdf`);
getPdf: async (id: string, regenerate = false): Promise<{ downloadUrl: string } | null> => {
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;
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 { useParams, useNavigate, useBlocker } from 'react-router-dom';
import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import {
Box,
@ -17,6 +17,9 @@ import {
DialogActions,
CircularProgress,
Alert,
Menu,
MenuItem,
IconButton,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
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 HistoryIcon from '@mui/icons-material/History';
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 { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
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 { 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';
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() {
const { id } = useParams<{ id: string }>();
@ -42,10 +78,15 @@ export default function AdminWorkspace() {
const [lineItems, setLineItems] = useState<EditableLineItem[]>([]);
const [refinedScope, setRefinedScope] = useState('');
const [workOrderNumber, setWorkOrderNumber] = useState('');
const [poNumber, setPoNumber] = useState('');
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [reviseDialogOpen, setReviseDialogOpen] = useState(false);
const [returnToReviewDialogOpen, setReturnToReviewDialogOpen] = 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>({
queryKey: [QUERY_KEYS.proposals, id],
@ -59,6 +100,18 @@ export default function AdminWorkspace() {
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(() => {
if (fetchedLineItems) {
setLineItems(
@ -80,9 +133,14 @@ export default function AdminWorkspace() {
useEffect(() => {
if (proposal) {
setRefinedScope(proposal.refinedScope || proposal.scopeOfWork);
setWorkOrderNumber(proposal.workOrderNumber || '');
setPoNumber(proposal.poNumber || '');
}
}, [proposal]);
const dirtyRef = useRef(dirty);
dirtyRef.current = dirty;
useEffect(() => {
if (!dirty) return;
const handler = (e: BeforeUnloadEvent) => {
@ -92,17 +150,9 @@ export default function AdminWorkspace() {
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 adminApi.updateProposal(id!, { refinedScope, workOrderNumber, poNumber });
await lineItemsApi.bulkUpdate(
id!,
lineItems.map((li) => ({
@ -121,6 +171,7 @@ export default function AdminWorkspace() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: ['audit', id] });
setDirty(false);
toast.success('Changes saved');
},
@ -136,6 +187,7 @@ export default function AdminWorkspace() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] });
queryClient.invalidateQueries({ queryKey: ['audit', id] });
setApproveDialogOpen(false);
setDirty(false);
toast.success('Proposal approved');
@ -146,6 +198,7 @@ export default function AdminWorkspace() {
mutationFn: () => adminApi.sendProposal(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: ['audit', id] });
setSendDialogOpen(false);
toast.success('Proposal marked as sent');
},
@ -155,11 +208,22 @@ export default function AdminWorkspace() {
mutationFn: () => adminApi.reviseProposal(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: ['audit', id] });
setReviseDialogOpen(false);
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({
mutationFn: () => adminApi.generateSuggestions(id!),
onSuccess: () => {
@ -168,11 +232,12 @@ export default function AdminWorkspace() {
});
const pdfMutation = useMutation({
mutationFn: () => adminApi.getPdf(id!),
mutationFn: (regenerate?: boolean) => adminApi.getPdf(id!, regenerate ?? false),
onSuccess: (data) => {
if (data?.downloadUrl) {
window.open(data.downloadUrl, '_blank');
toast.success('PDF ready — downloading');
queryClient.invalidateQueries({ queryKey: ['audit', id] });
toast.success('PDF ready');
} else {
toast.info('PDF generation queued — try again in a few seconds');
}
@ -187,6 +252,29 @@ export default function AdminWorkspace() {
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) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
@ -215,38 +303,116 @@ export default function AdminWorkspace() {
<Box>
{/* Header */}
<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
</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'} />
<Chip
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>
{/* Proposal Info Bar */}
<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>
<Typography variant="caption" color="text.secondary">Customer</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.customerName}</Typography>
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>Customer</Typography>
<Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{proposal.customerName}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">WO #</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.workOrderNumber}</Typography>
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>Site</Typography>
<Typography sx={{ fontSize: '0.875rem', lineHeight: 1.5, fontWeight: 600 }}>{proposal.customerAddress}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Category</Typography>
<Typography variant="body2">{proposal.serviceCategory}</Typography>
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>WO #</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>
<Typography variant="caption" color="text.secondary">Priority</Typography>
<Chip label={proposal.priority} size="small" sx={{ ml: 0.5 }} />
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>PO #</Typography>
{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>
<Typography variant="caption" color="text.secondary">Address</Typography>
<Typography variant="body2">{proposal.customerAddress}</Typography>
<Typography sx={{ fontSize: '0.75rem', lineHeight: 1.5, color: 'text.secondary' }}>Category</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>
</CardContent>
</Card>
@ -375,10 +541,19 @@ export default function AdminWorkspace() {
{isApproved && (
<>
<Button
variant="outlined"
color="warning"
startIcon={<UndoIcon />}
onClick={() => setReturnToReviewDialogOpen(true)}
disabled={returnToReviewMutation.isPending}
>
Return to Review
</Button>
<Button
variant="contained"
startIcon={<PictureAsPdfIcon />}
onClick={() => pdfMutation.mutate()}
onClick={() => pdfMutation.mutate(false)}
disabled={pdfMutation.isPending}
>
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
@ -396,32 +571,39 @@ export default function AdminWorkspace() {
)}
{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
variant="outlined"
startIcon={<HistoryIcon />}
onClick={() => setReviseDialogOpen(true)}
disabled={reviseMutation.isPending}
startIcon={<DownloadIcon />}
onClick={() => handleDownloadPdf(id!, proposal.proposalNumber, proposal.currentRevision)}
disabled={downloadingRevId === id}
>
Create Revision
Download PDF
</Button>
)}
</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>
@ -496,6 +678,71 @@ export default function AdminWorkspace() {
</Button>
</DialogActions>
</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>
);
}