diff --git a/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs index 4ac926d..cf77689 100644 --- a/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs +++ b/api/src/ProposalSystem.Application/DTOs/ProposalDtos.cs @@ -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, diff --git a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs index aa684cc..adfed94 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/ProposalService.cs @@ -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 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 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, diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index c0eb2e3..47df6b8 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -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 => { + await apiClient.post(`/proposals/${id}/return-to-review`); + }, + sendProposal: async (id: string): Promise => { await apiClient.post(`/proposals/${id}/send`); }, @@ -46,7 +53,7 @@ export const adminApi = { await apiClient.post(`/proposals/${id}/revise`); }, - getHistory: async (id: string): Promise => { + getHistory: async (id: string): Promise => { 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 }; }, }; diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index 66c08ee..e2bff15 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -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 = { + 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([]); 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); + const [downloadingRevId, setDownloadingRevId] = useState(null); const { data: proposal, isLoading } = useQuery({ queryKey: [QUERY_KEYS.proposals, id], @@ -59,6 +100,18 @@ export default function AdminWorkspace() { enabled: !!id, }); + const { data: auditTrail } = useQuery({ + queryKey: ['audit', id], + queryFn: () => adminApi.getAudit(id!), + enabled: !!id, + }); + + const { data: revisionHistory } = useQuery({ + 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 ( @@ -215,38 +303,116 @@ export default function AdminWorkspace() { {/* Header */} - {proposal.proposalNumber} - - + setRevMenuAnchor(e.currentTarget)} + onDelete={(e) => setRevMenuAnchor(e.currentTarget as HTMLElement)} + deleteIcon={} + sx={{ cursor: 'pointer' }} + /> + setRevMenuAnchor(null)} + > + {(revisionHistory ?? []) + .sort((a, b) => a.currentRevision - b.currentRevision) + .map((rev) => ( + { + 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 }} + > + + Rev {rev.currentRevision} + {STATUS_LABELS[rev.status] || rev.status} + + {DOWNLOADABLE_STATUSES.includes(rev.status) && ( + { + e.stopPropagation(); + setRevMenuAnchor(null); + handleDownloadPdf(rev.id, rev.proposalNumber, rev.currentRevision); + }} + disabled={downloadingRevId === rev.id} + > + + + )} + + ))} + + {/* Proposal Info Bar */} - + - Customer - {proposal.customerName} + Customer + {proposal.customerName} - WO # - {proposal.workOrderNumber} + Site + {proposal.customerAddress} - Category - {proposal.serviceCategory} + WO # + {isEditable ? ( + { 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 } }} + /> + ) : ( + {workOrderNumber || '—'} + )} - Priority - + PO # + {isEditable ? ( + { 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 } }} + /> + ) : ( + {poNumber || '—'} + )} - Address - {proposal.customerAddress} + Category + {proposal.serviceCategory} + + + Priority + {PRIORITY_LABELS[proposal.priority] || proposal.priority} @@ -375,10 +541,19 @@ export default function AdminWorkspace() { {isApproved && ( <> + + + + )} + + {proposal.status === 'Revised' && ( )} - {/* Unsaved Changes Navigation Guard */} - blocker.reset?.()}> - Unsaved Changes - - You have unsaved changes. Are you sure you want to leave? - - - - - - - {/* Approve Confirmation Dialog */} setApproveDialogOpen(false)}> Approve Proposal @@ -496,6 +678,71 @@ export default function AdminWorkspace() { + + {/* Return to Review Confirmation Dialog */} + setReturnToReviewDialogOpen(false)}> + Return to Review + + + Return {proposal.proposalNumber} to review? + This will clear the approval and allow further editing. + + + + + + + + + {/* Audit Trail */} + {auditTrail && auditTrail.length > 0 && ( + + + Activity Log + + + + {auditTrail.map((entry) => ( + + + + {new Date(entry.timestamp).toLocaleString()} + + + + + + + {entry.userName} + + + + {entry.details ? formatDetails(entry.details) : ''} + + + + ))} + + + + + + )} ); }