mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 07:43:14 +00:00
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:
parent
f37c2aa3f0
commit
9b97b7b578
5 changed files with 31 additions and 5 deletions
|
|
@ -67,6 +67,7 @@ public record ProposalFilterRequest(
|
|||
DateTime? FromDate,
|
||||
DateTime? ToDate,
|
||||
string? Search,
|
||||
bool Mine = false,
|
||||
int Page = 1,
|
||||
int PageSize = 25
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue