Fix proposal workflow: scoped My Proposals, idempotent state transitions

Add Mine filter to ProposalFilterRequest so My Proposals page shows
only the current user's submissions regardless of role. Create
proposals directly as InReview (skip Draft) since form submission is
the review request. Make Approve, MarkSent, and Revise idempotent —
repeat calls return current state instead of throwing. Wrap line item
audit logging in try/catch so audit failures don't mask successful
saves.
This commit is contained in:
Adam Moussa 2026-05-20 17:24:09 -04:00
parent f37c2aa3f0
commit 9b97b7b578
5 changed files with 31 additions and 5 deletions

View file

@ -67,6 +67,7 @@ public record ProposalFilterRequest(
DateTime? FromDate,
DateTime? ToDate,
string? Search,
bool Mine = false,
int Page = 1,
int PageSize = 25
);

View file

@ -54,7 +54,11 @@ public class LineItemService : ILineItemService
_db.LineItems.Add(lineItem);
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Added: {request.Description}", ct);
try
{
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Added: {request.Description}", ct);
}
catch { /* audit failure should not roll back a successful save */ }
return MapToResponse(lineItem);
}
@ -97,7 +101,11 @@ public class LineItemService : ILineItemService
await _db.SaveChangesAsync(ct);
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Bulk update: {newItems.Count} items", ct);
try
{
await _audit.LogAsync(AuditAction.EditLineItem, proposalId, $"Bulk update: {newItems.Count} items", ct);
}
catch { /* audit failure should not roll back a successful save */ }
return newItems.OrderBy(li => li.SortOrder).Select(MapToResponse).ToList();
}

View file

@ -43,7 +43,7 @@ public class ProposalService : IProposalService
ScopeOfWork = request.ScopeOfWork,
ServiceCategory = request.ServiceCategory,
Priority = request.Priority,
Status = ProposalStatus.Draft,
Status = ProposalStatus.InReview,
Notes = request.Notes ?? string.Empty,
SubmittedById = _currentUser.UserId,
SubmittedAt = now,
@ -79,7 +79,7 @@ public class ProposalService : IProposalService
.Include(p => p.AssignedAdmin)
.AsQueryable();
if (_currentUser.Role == UserRole.Dispatcher)
if (filter.Mine)
{
query = query.Where(p => p.SubmittedById == _currentUser.UserId);
}
@ -166,6 +166,9 @@ public class ProposalService : IProposalService
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.Approved)
return MapToResponse(proposal);
if (proposal.Status != ProposalStatus.InReview)
throw new InvalidOperationException("Only proposals in review can be approved");
@ -189,6 +192,9 @@ public class ProposalService : IProposalService
var proposal = await _db.Proposals.FindAsync(new object[] { id }, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.Sent)
return MapToResponse(proposal);
if (proposal.Status != ProposalStatus.Approved)
throw new InvalidOperationException("Only approved proposals can be marked as sent");
@ -211,6 +217,14 @@ public class ProposalService : IProposalService
.FirstOrDefaultAsync(p => p.Id == id, ct)
?? throw new KeyNotFoundException($"Proposal {id} not found");
if (proposal.Status == ProposalStatus.Revised)
{
var existingRevision = await _db.Proposals
.FirstOrDefaultAsync(p => p.ParentProposalId == proposal.Id, ct);
if (existingRevision != null)
return MapToResponse(existingRevision);
}
if (proposal.Status != ProposalStatus.Sent)
throw new InvalidOperationException("Only sent proposals can be revised");

View file

@ -67,6 +67,7 @@ export interface ProposalFilters {
priority?: string;
fromDate?: string;
toDate?: string;
mine?: boolean;
}
export const proposalsApi = {
@ -83,6 +84,7 @@ export const proposalsApi = {
if (filters.status) params.append('status', filters.status);
if (filters.serviceCategory) params.append('serviceCategory', filters.serviceCategory);
if (filters.priority) params.append('priority', filters.priority);
if (filters.mine) params.append('mine', 'true');
const res = await apiClient.get(`/proposals?${params.toString()}`);
return res.data;

View file

@ -40,6 +40,7 @@ function formatDate(iso: string): string {
export default function ProposalListPage() {
const navigate = useNavigate();
const mineParams = { mine: true };
const {
rows,
search,
@ -51,7 +52,7 @@ export default function ProposalListPage() {
totalCount,
loading,
err,
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll);
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, mineParams);
return (
<Box>