mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-04 17:12:02 +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(
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue