mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 06:33:13 +00:00
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:
parent
cab97cbb1b
commit
6de4bafec4
4 changed files with 351 additions and 53 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue