diff --git a/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs index a91d059..cac275a 100644 --- a/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs @@ -197,6 +197,48 @@ public class ExpenseServiceTests bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false)); } + [Fact] + public async Task Update_OwnPendingApproval_AsNonFinance_Succeeds() + { + // After Submit a reimbursement sits in PendingApproval; the owner may still correct it. + var (svc, db, _) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.SubmitAsync(id); + Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status); + + var edit = CloneToUpdate(Reimb()); + edit.Amount = 99.99m; + await svc.UpdateAsync(id, edit, isFinance: false); + + var e = await db.Expenses.FindAsync(id); + Assert.Equal(99.99m, e!.Amount); + Assert.Equal("PendingApproval", e.Status); + } + + [Fact] + public async Task Update_OwnApproved_AsNonFinance_Throws() + { + // Once approved, the owner can no longer edit. + var (svc, db, fs) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.SubmitAsync(id); + await SvcAs(db, fs, "finance").ApproveAsync(id); + + await Assert.ThrowsAsync(() => + svc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false)); + } + + [Fact] + public async Task SaveReceipt_OwnPendingApproval_AsNonFinance_Succeeds() + { + var (svc, db, _) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.SubmitAsync(id); + using var input = new MemoryStream(Encoding.UTF8.GetBytes("img")); + await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false); + Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true)); + } + [Fact] public async Task SoftDelete_HidesFromQueries() { diff --git a/API/ROLAC.API/Services/ExpenseService.cs b/API/ROLAC.API/Services/ExpenseService.cs index c838363..e81eefa 100644 --- a/API/ROLAC.API/Services/ExpenseService.cs +++ b/API/ROLAC.API/Services/ExpenseService.cs @@ -174,8 +174,8 @@ public class ExpenseService : IExpenseService // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies. var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException($"Expense {id} not found."); - if (!isFinance && !(e.SubmittedBy == CurrentUserId && e.Status == "Draft")) - throw new InvalidOperationException("You can only edit your own draft reimbursements."); + if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval"))) + throw new InvalidOperationException("You can only edit your own draft or pending reimbursements."); e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId; e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber; @@ -245,8 +245,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) - throw new InvalidOperationException("You can only attach receipts to your own reimbursements."); + 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."); var safe = Path.GetFileName(fileName).Replace(' ', '_'); var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}"; diff --git a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html index 8b0d468..dc49b79 100644 --- a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html +++ b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html @@ -1,4 +1,4 @@ - +
diff --git a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html index ca4501e..f02835c 100644 --- a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html +++ b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html @@ -68,8 +68,9 @@ - + + @@ -93,6 +94,12 @@ title="Reimbursement (on behalf)" (save)="onReimbSave($event)" (cancel)="reimbDialogOpen = false"> + + + +
diff --git a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts index 318d3b8..9a25e49 100644 --- a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts +++ b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts @@ -39,6 +39,9 @@ export class ExpensesPageComponent implements OnInit { vendorDialogOpen = false; reimbDialogOpen = false; + editRow: ExpenseListItemDto | null = null; + editMode: 'vendor' | 'reimbursement' = 'reimbursement'; + payRow: ExpenseListItemDto | null = null; payCheckNumber = ''; payDate = new Date(); @@ -82,6 +85,21 @@ export class ExpensesPageComponent implements OnInit { ).subscribe(() => { this.reimbDialogOpen = false; this.load(); }); } + openEdit(row: ExpenseListItemDto): void { + this.editRow = row; + this.editMode = row.type === 'VendorPayment' ? 'vendor' : 'reimbursement'; + } + + closeEdit(): void { this.editRow = null; } + + onEditSave(result: ExpenseFormResult): void { + if (!this.editRow) return; + const id = this.editRow.id; + this.api.update(id, result.request).pipe( + switchMap(() => result.receipt ? this.api.uploadReceipt(id, result.receipt) : of(void 0)), + ).subscribe(() => { this.closeEdit(); this.load(); }); + } + approve(row: ExpenseListItemDto): void { this.api.approve(row.id).subscribe(() => this.load()); } @@ -123,6 +141,8 @@ export class ExpensesPageComponent implements OnInit { }); } + /** Finance may edit (and reupload the receipt) while the expense is still Draft or awaiting review. */ + canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft' || row.status === 'PendingApproval'; } canApproveOrReject(row: ExpenseListItemDto): boolean { return row.status === 'PendingApproval'; } canPay(row: ExpenseListItemDto): boolean { return false; diff --git a/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.html b/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.html index 29b67b2..0dc3598 100644 --- a/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.html +++ b/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.html @@ -3,33 +3,71 @@ - - - - - - - {{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }} - - - - - - {{ dataItem.status }} - - - - - - - - - - - - - + + + + +
+
Loading…
+
No reimbursements yet.
+ +
+
+ {{ row.expenseDate }} + {{ row.amount | currency:'USD':'symbol':'1.2-2' }} +
+ +
{{ row.description }}
+ +
+
+
Ministry
+
{{ row.ministryName }}
+
+
+
Category
+
{{ row.categoryGroupName }} / {{ row.subCategoryName }}
+
+
+ + + +
+ + + + +
+
+
)[status] ?? ''; }