add approve.
ci-cd-vm / ci-cd (push) Successful in 2m24s

This commit is contained in:
Chris Chen
2026-06-25 10:22:01 -07:00
parent 8bdb942a49
commit fa3e75a333
15 changed files with 506 additions and 46 deletions
+47 -6
View File
@@ -85,6 +85,7 @@ public class ExpenseService : IExpenseService
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
var memNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}");
var reviewerNames = await ResolveUserNamesAsync(rows.Select(r => r.ReviewedBy));
// Line count + first line's category, per expense on this page.
var expenseIds = rows.Select(r => r.Id).ToList();
@@ -111,12 +112,39 @@ public class ExpenseService : IExpenseService
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber,
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
ReviewedAt = e.ReviewedAt,
ReviewNotes = e.ReviewNotes,
};
}).ToList();
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
// Resolve actor user ids (AppUser.Id, stored in ReviewedBy/SubmittedBy/PaidBy) to a display name:
// the linked Member's full name when present, otherwise the account email.
private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
{
var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
if (ids.Count == 0) return new Dictionary<string, string>();
var users = await _db.Users.AsNoTracking()
.Where(u => ids.Contains(u.Id))
.Select(u => new { u.Id, u.Email, u.MemberId })
.ToListAsync();
var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
var memberNames = await _db.Members.AsNoTracking()
.Where(m => memberIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());
return users.ToDictionary(
u => u.Id,
u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
? name
: (u.Email ?? u.Id));
}
public async Task<ExpenseDto?> GetByIdAsync(int id)
{
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
@@ -126,6 +154,10 @@ public class ExpenseService : IExpenseService
? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync()
: null;
var reviewerName = e.ReviewedBy != null
? (await ResolveUserNamesAsync(new[] { e.ReviewedBy })).GetValueOrDefault(e.ReviewedBy)
: null;
var lines = await _db.ExpenseLines.AsNoTracking().Where(l => l.ExpenseId == id).OrderBy(l => l.Id).ToListAsync();
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => s.Name_en);
@@ -145,7 +177,8 @@ public class ExpenseService : IExpenseService
VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName,
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt,
ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
Lines = lineDtos,
};
}
@@ -225,8 +258,8 @@ public class ExpenseService : IExpenseService
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
var e = await _db.Expenses.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Expense {id} not found.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements.");
e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
@@ -258,8 +291,11 @@ public class ExpenseService : IExpenseService
{
var e = await RequireAsync(id);
if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement.");
if (e.Status != "Draft") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'.");
// Draft (first submit) or Rejected (re-submit after fixing the flagged issue, e.g. a clearer receipt).
if (e.Status != "Draft" && e.Status != "Rejected") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'.");
e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow;
// Clear the prior review so the expense returns to a clean pending state.
e.ReviewedBy = null; e.ReviewedAt = null; e.ReviewNotes = null;
await _db.SaveChangesAsync();
}
@@ -282,6 +318,11 @@ public class ExpenseService : IExpenseService
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'.");
e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes;
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.ExpenseRejected, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(Expense), entityId: e.Id.ToString(),
summary: $"Expense #{e.Id} rejected: {e.Description} — {reviewNotes}");
}
public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt)
@@ -300,8 +341,8 @@ public class ExpenseService : IExpenseService
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
{
var e = await RequireAsync(id);
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
throw new InvalidOperationException("You can only attach receipts to your own draft or pending reimbursements.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
throw new InvalidOperationException("You can only attach receipts to your own draft, pending, or rejected reimbursements.");
var safe = Path.GetFileName(fileName).Replace(' ', '_');
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";