diff --git a/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs index d3ccd0e..a903445 100644 --- a/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs @@ -7,8 +7,11 @@ using ROLAC.API.Data; using ROLAC.API.Data.Interceptors; using ROLAC.API.DTOs.Expense; using ROLAC.API.Entities; +using ROLAC.API.Entities.Logging; using ROLAC.API.Services; +using ROLAC.API.Services.Logging; using ROLAC.API.Services.Storage; +using ROLAC.API.Tests.TestSupport; using Xunit; namespace ROLAC.API.Tests.Services; @@ -55,6 +58,14 @@ public class ExpenseServiceTests return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); } + private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId, IAuditLogger audit) + { + var http = new Mock(); + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; + http.Setup(x => x.HttpContext).Returns(ctx); + return new ExpenseService(db, http.Object, fs, audit); + } + // Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier), // mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false). private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId) @@ -342,4 +353,93 @@ public class ExpenseServiceTests var got = await svc.OpenReceiptAsync(id, isFinance: true); Assert.NotNull(got); } + + [Fact] + public async Task Reject_WritesAuditEntry_WithReason() + { + var (svc, db, fs) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.SubmitAsync(id); + + var audit = new CapturingAuditLogger(); + await SvcAs(db, fs, "finance", audit).RejectAsync(id, "Receipt unclear, please retake"); + + var entry = Assert.Single(audit.Entries); + Assert.Equal(AuditActions.ExpenseRejected, entry.Action); + Assert.Equal(AuditCategories.Business, entry.Category); + Assert.Equal(nameof(ROLAC.API.Entities.Expense), entry.EntityName); + Assert.Equal(id.ToString(), entry.EntityId); + Assert.Contains("Receipt unclear", entry.Summary); + } + + [Fact] + public async Task Resubmit_FromRejected_ReturnsToPending_AndClearsReview() + { + var (svc, db, fs) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.SubmitAsync(id); + await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt missing"); + + // Owner fixes the issue and re-submits. + await svc.SubmitAsync(id); + + var e = await db.Expenses.FindAsync(id); + Assert.Equal("PendingApproval", e!.Status); + Assert.Null(e.ReviewedBy); + Assert.Null(e.ReviewedAt); + Assert.Null(e.ReviewNotes); + } + + [Fact] + public async Task Update_OwnRejected_AsNonFinance_Succeeds() + { + // A rejected reimbursement can be corrected by its owner before re-submitting. + var (svc, db, fs) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.SubmitAsync(id); + await SvcAs(db, fs, "finance").RejectAsync(id, "Amount does not match receipt"); + + var edit = CloneToUpdate(Reimb()); + edit.Lines[0].Amount = 77.77m; + await svc.UpdateAsync(id, edit, isFinance: false); + + var e = await db.Expenses.FindAsync(id); + Assert.Equal(77.77m, e!.Amount); + Assert.Equal("Rejected", e.Status); + } + + [Fact] + public async Task SaveReceipt_OwnRejected_AsNonFinance_Succeeds() + { + var (svc, db, fs) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.SubmitAsync(id); + await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt unclear, please retake"); + + using var input = new MemoryStream(Encoding.UTF8.GetBytes("img")); + await svc.SaveReceiptAsync(id, input, "retake.jpg", isFinance: false); + Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true)); + } + + [Fact] + public async Task GetById_ResolvesReviewerName_MemberFullName_EmailFallback() + { + var (svc, db, fs) = Build("alice"); + // Reviewer linked to a member → shows the member's full name. + db.Members.Add(new Member { Id = 5, FirstName_en = "Sam", LastName_en = "Approver" }); + db.Users.Add(new AppUser { Id = "reviewer-with-member", MemberId = 5 }); + // Reviewer with no member → falls back to email. + db.Users.Add(new AppUser { Id = "reviewer-no-member", Email = "nomember@church.org" }); + await db.SaveChangesAsync(); + + var withMember = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.SubmitAsync(withMember); + await SvcAs(db, fs, "reviewer-with-member").ApproveAsync(withMember); + Assert.Equal("Sam Approver", (await svc.GetByIdAsync(withMember))!.ReviewedByName); + + var noMember = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.SubmitAsync(noMember); + await SvcAs(db, fs, "reviewer-no-member").RejectAsync(noMember, "Duplicate submission"); + Assert.Equal("nomember@church.org", (await svc.GetByIdAsync(noMember))!.ReviewedByName); + } } diff --git a/API/ROLAC.API.Tests/TestSupport/CapturingAuditLogger.cs b/API/ROLAC.API.Tests/TestSupport/CapturingAuditLogger.cs new file mode 100644 index 0000000..5fd1181 --- /dev/null +++ b/API/ROLAC.API.Tests/TestSupport/CapturingAuditLogger.cs @@ -0,0 +1,21 @@ +using ROLAC.API.Entities.Logging; +using ROLAC.API.Services.Logging; + +namespace ROLAC.API.Tests.TestSupport; + +/// Records every audit Write so tests can assert on the emitted actions/summaries. +public sealed class CapturingAuditLogger : IAuditLogger +{ + public readonly record struct Entry(string Action, string Category, string? EntityName, string? EntityId, string? Summary); + + public readonly List Entries = new(); + + public void Write( + string action, string category, LogLevelEnum level = LogLevelEnum.Information, + string? entityName = null, string? entityId = null, string? summary = null, + object? before = null, object? after = null, + string? userId = null, string? userEmail = null, string? ipAddress = null) + { + Entries.Add(new Entry(action, category, entityName, entityId, summary)); + } +} diff --git a/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs b/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs index f4444b9..e0278ab 100644 --- a/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs +++ b/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs @@ -30,15 +30,17 @@ public class ExpenseListItemDto public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd public bool HasReceipt { get; set; } public string? CheckNumber { get; set; } + // Review outcome — surfaced on the list so the Status column can show "Approved/Rejected by X · date". + public string? ReviewedByName { get; set; } // resolved Member full name, email fallback + public DateTimeOffset? ReviewedAt { get; set; } + public string? ReviewNotes { get; set; } // reject reason (or approval note) } public class ExpenseDto : ExpenseListItemDto { public string? Notes { get; set; } - public string? ReviewNotes { get; set; } public string? SubmittedBy { get; set; } public DateTimeOffset? SubmittedAt { get; set; } - public DateTimeOffset? ReviewedAt { get; set; } public DateTimeOffset? PaidAt { get; set; } public List Lines { get; set; } = new(); } diff --git a/API/ROLAC.API/Entities/Logging/AuditLog.cs b/API/ROLAC.API/Entities/Logging/AuditLog.cs index ca8e765..7294179 100644 --- a/API/ROLAC.API/Entities/Logging/AuditLog.cs +++ b/API/ROLAC.API/Entities/Logging/AuditLog.cs @@ -53,6 +53,7 @@ public static class AuditActions public const string CheckIssued = "CheckIssued"; public const string CheckVoided = "CheckVoided"; public const string ExpenseApproved = "ExpenseApproved"; + public const string ExpenseRejected = "ExpenseRejected"; public const string StatementFinalized = "StatementFinalized"; public static readonly IReadOnlyList All = @@ -60,7 +61,7 @@ public static class AuditActions Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged, PasswordChanged, UserDeactivated, PermissionChanged, InvitationCreated, InvitationAccepted, CheckIssued, - CheckVoided, ExpenseApproved, StatementFinalized, + CheckVoided, ExpenseApproved, ExpenseRejected, StatementFinalized, ]; } diff --git a/API/ROLAC.API/Services/ExpenseService.cs b/API/ROLAC.API/Services/ExpenseService.cs index 64afbac..1a407d9 100644 --- a/API/ROLAC.API/Services/ExpenseService.cs +++ b/API/ROLAC.API/Services/ExpenseService.cs @@ -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 { 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> ResolveUserNamesAsync(IEnumerable userIds) + { + var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList(); + if (ids.Count == 0) return new Dictionary(); + + 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 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}"; diff --git a/APP/src/app/features/expense/components/expense-review-dialog/expense-review-dialog.component.html b/APP/src/app/features/expense/components/expense-review-dialog/expense-review-dialog.component.html new file mode 100644 index 0000000..5f93b33 --- /dev/null +++ b/APP/src/app/features/expense/components/expense-review-dialog/expense-review-dialog.component.html @@ -0,0 +1,98 @@ + + +
Loading… / 載入中…
+ +
+ + +
+
+
Date / 日期
{{ expense.expenseDate }}
+
Ministry / 事工
{{ expense.ministryName }}
+
Payee / 收款人
{{ expense.vendorName || expense.memberName || '—' }}
+
Description / 說明
{{ expense.description }}
+
Status / 狀態
{{ expense.status }}
+
+ + +
+
明細 / Line Items
+ + + + + + + + + + + + + + + + + + + + + +
Category / 類別Description / 說明Amount / 金額
{{ line.categoryGroupName }} / {{ line.subCategoryName }}{{ line.description || '—' }}{{ line.amount | currency }}
Total / 合計{{ expense.amount | currency }}
+
+ + +
+ + +
+
+ + +
+
+ 收據預覽 / Receipt +
+ + {{ receiptZoom * 100 | number:'1.0-0' }}% + + +
+
+ +
+ Receipt preview +
+ + +
+ No receipt attached / 無收據 +
+
+ +
+ + + + + + + + + + + + + + +
diff --git a/APP/src/app/features/expense/components/expense-review-dialog/expense-review-dialog.component.ts b/APP/src/app/features/expense/components/expense-review-dialog/expense-review-dialog.component.ts new file mode 100644 index 0000000..c215531 --- /dev/null +++ b/APP/src/app/features/expense/components/expense-review-dialog/expense-review-dialog.component.ts @@ -0,0 +1,106 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ExpenseApiService } from '../../services/expense-api.service'; +import { ExpenseDto } from '../../models/expense.model'; +import { EXPENSE_REJECT_REASON_OPTIONS } from '../../../../shared/i18n/option-lists'; + +/** + * Approval review dialog: shows the full expense detail and a receipt preview side by side, + * then lets a reviewer Approve or Reject (with a templated or free-text reason). The parent + * performs the actual api.approve / api.reject call from the emitted events. + */ +@Component({ + selector: 'app-expense-review-dialog', + standalone: true, + imports: [CommonModule, FormsModule, ButtonsModule, DialogsModule, DropDownsModule, InputsModule], + templateUrl: './expense-review-dialog.component.html', +}) +export class ExpenseReviewDialogComponent implements OnInit, OnDestroy { + /** Expense to review; the full detail (with lines) is fetched on open. */ + @Input() expenseId!: number; + @Output() approve = new EventEmitter(); + @Output() reject = new EventEmitter(); // emits the composed reviewNotes + @Output() cancel = new EventEmitter(); + + expense: ExpenseDto | null = null; + loading = true; + + readonly rejectReasons = EXPENSE_REJECT_REASON_OPTIONS; + /** false = the Approve/Reject choice; true = the reject reason is being collected. */ + rejecting = false; + rejectReason: string | null = null; + rejectOther = ''; + + // ── Receipt preview (mirrors expense-form-dialog) ─────────────────────── + receiptImageUrl: string | null = null; + receiptPdfUrl: SafeResourceUrl | null = null; + private receiptObjectUrl: string | null = null; + receiptZoom = 1; + readonly minZoom = 0.5; + readonly maxZoom = 5; + + get showReceiptPanel(): boolean { return !!(this.receiptImageUrl || this.receiptPdfUrl); } + + zoomIn(): void { this.receiptZoom = Math.min(this.maxZoom, +(this.receiptZoom + 0.25).toFixed(2)); } + zoomOut(): void { this.receiptZoom = Math.max(this.minZoom, +(this.receiptZoom - 0.25).toFixed(2)); } + resetZoom(): void { this.receiptZoom = 1; } + + constructor(private api: ExpenseApiService, private sanitizer: DomSanitizer) {} + + ngOnInit(): void { + this.api.getById(this.expenseId).subscribe({ + next: dto => { + this.expense = dto; + this.loading = false; + if (dto.hasReceipt) { + this.api.downloadReceipt(dto.id).subscribe(blob => this.setPreview(blob, blob.type)); + } + }, + error: () => { this.loading = false; }, + }); + } + + ngOnDestroy(): void { this.clearPreview(); } + + private setPreview(blob: Blob, contentType: string): void { + this.clearPreview(); + this.receiptZoom = 1; + this.receiptObjectUrl = URL.createObjectURL(blob); + if (contentType.startsWith('image/')) { + this.receiptImageUrl = this.receiptObjectUrl; + } else { + this.receiptPdfUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.receiptObjectUrl); + } + } + + private clearPreview(): void { + if (this.receiptObjectUrl) { URL.revokeObjectURL(this.receiptObjectUrl); this.receiptObjectUrl = null; } + this.receiptImageUrl = null; + this.receiptPdfUrl = null; + } + + startReject(): void { this.rejecting = true; this.rejectReason = null; this.rejectOther = ''; } + cancelReject(): void { this.rejecting = false; } + + get isOtherReason(): boolean { return this.rejectReason === 'Other'; } + + /** The reason text actually sent: the template value, or the free text when "Other" is chosen. */ + get composedReviewNotes(): string { + return this.isOtherReason ? this.rejectOther.trim() : (this.rejectReason ?? ''); + } + + get canConfirmReject(): boolean { return this.composedReviewNotes.length > 0; } + + confirmApprove(): void { this.approve.emit(); } + + confirmReject(): void { + if (!this.canConfirmReject) return; + this.reject.emit(this.composedReviewNotes); + } +} diff --git a/APP/src/app/features/expense/models/expense.model.ts b/APP/src/app/features/expense/models/expense.model.ts index 32e8ea7..8215799 100644 --- a/APP/src/app/features/expense/models/expense.model.ts +++ b/APP/src/app/features/expense/models/expense.model.ts @@ -26,10 +26,11 @@ export interface ExpenseListItemDto { vendorName: string | null; memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean; checkNumber: string | null; + reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null; } export interface ExpenseDto extends ExpenseListItemDto { - notes: string | null; reviewNotes: string | null; - submittedBy: string | null; submittedAt: string | null; reviewedAt: string | null; paidAt: string | null; + notes: string | null; + submittedBy: string | null; submittedAt: string | null; paidAt: string | null; lines: ExpenseLineItemDto[]; } export interface ExpenseLineInput { 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 3f1e059..30590ab 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 @@ -62,19 +62,23 @@ - + {{ dataItem.status }} +
✓ Approved by {{ dataItem.reviewedByName }}
{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
+
+ ✗ Rejected by {{ dataItem.reviewedByName }}
{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }} +
{{ dataItem.reviewNotes }}
+
- - - - + - - - + + +
{{ toast }}
diff --git a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.scss b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.scss index 0da4322..c789e54 100644 --- a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.scss +++ b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.scss @@ -45,6 +45,24 @@ text-decoration: underline; } +// Small "Approved/Rejected by X · date" note under the status badge. +.review-meta { + margin-top: 4px; + font-size: 0.7rem; + line-height: 1.2; + color: #6b7280; +} + +.review-meta-reject { + color: #b91c1c; +} + +.review-reason { + margin-top: 2px; + font-style: italic; + color: #991b1b; +} + // Save confirmation pill. z-index sits above the Kendo dialog overlay so it // stays visible while the continuous-entry dialog remains open. .save-toast { 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 9e006a8..2c7096d 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 @@ -11,6 +11,7 @@ import { EXPENSE_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists'; import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service'; import { MinistryApiService } from '../../services/ministry-api.service'; import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component'; +import { ExpenseReviewDialogComponent } from '../../components/expense-review-dialog/expense-review-dialog.component'; import { ExpenseDto, ExpenseListItemDto, MinistryDto } from '../../models/expense.model'; import { switchMap, of } from 'rxjs'; @@ -20,6 +21,7 @@ import { switchMap, of } from 'rxjs'; imports: [ CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule, InputsModule, DialogsModule, DateInputsModule, ExpenseFormDialogComponent, + ExpenseReviewDialogComponent, ], templateUrl: './expenses-page.component.html', styleUrls: ['./expenses-page.component.scss'], @@ -46,8 +48,8 @@ export class ExpensesPageComponent implements OnInit { payCheckNumber = ''; payDate = new Date(); - rejectRow: ExpenseListItemDto | null = null; - rejectNotes = ''; + /** Row whose detail+receipt are open in the review dialog for an approve/reject decision. */ + reviewRow: ExpenseListItemDto | null = null; /** Transient confirmation pill, used so the user gets feedback during continuous entry. */ toast: string | null = null; @@ -110,19 +112,18 @@ export class ExpensesPageComponent implements OnInit { ).subscribe(() => { this.closeEdit(); this.load(); }); } - approve(row: ExpenseListItemDto): void { - this.api.approve(row.id).subscribe(() => this.load()); + openReview(row: ExpenseListItemDto): void { this.reviewRow = row; } + closeReview(): void { this.reviewRow = null; } + + onReviewApprove(): void { + if (!this.reviewRow) return; + this.api.approve(this.reviewRow.id).subscribe(() => { this.reviewRow = null; this.load(); }); } - openReject(row: ExpenseListItemDto): void { - this.rejectRow = row; - this.rejectNotes = ''; - } - - confirmReject(): void { - if (!this.rejectRow) return; - this.api.reject(this.rejectRow.id, { reviewNotes: this.rejectNotes || null }).subscribe(() => { - this.rejectRow = null; + onReviewReject(reviewNotes: string): void { + if (!this.reviewRow) return; + this.api.reject(this.reviewRow.id, { reviewNotes: reviewNotes || null }).subscribe(() => { + this.reviewRow = null; this.load(); }); } 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 6532a3f..f5dfd08 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 @@ -15,15 +15,22 @@
- + {{ dataItem.status }} +
✓ Approved by {{ dataItem.reviewedByName }}
{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
+
+ ✗ Rejected by {{ dataItem.reviewedByName }}
{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }} +
{{ dataItem.reviewNotes }}
+
- + + @@ -60,9 +67,20 @@ {{ row.status }} + +
+ ✗ Rejected by {{ row.reviewedByName }} + · {{ row.reviewedAt | date:'yyyy-MM-dd HH:mm' }} +
{{ row.reviewNotes }}
+
+
+ ✓ Approved by {{ row.reviewedByName }} · {{ row.reviewedAt | date:'yyyy-MM-dd HH:mm' }} +
+
+
diff --git a/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.scss b/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.scss index 88b237f..ff1c901 100644 --- a/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.scss +++ b/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.scss @@ -45,6 +45,24 @@ text-decoration: underline; } +// Small "Approved/Rejected by X · date" note under the status badge (desktop grid). +.review-meta { + margin-top: 4px; + font-size: 0.7rem; + line-height: 1.2; + color: #6b7280; +} + +.review-meta-reject { + color: #b91c1c; +} + +.review-reason { + margin-top: 2px; + font-style: italic; + color: #991b1b; +} + // Mobile card list // NOTE: display/flex layout lives on the element via Tailwind (flex flex-col gap-3) // so the responsive `md:hidden` utility wins on desktop. Setting `display: flex` @@ -116,6 +134,28 @@ margin-top: 12px; } + &__reject { + margin-top: 10px; + padding: 8px 10px; + border-radius: 8px; + background: #fef2f2; + border: 1px solid #fecaca; + font-size: 0.8rem; + color: #b91c1c; + } + + &__reject-reason { + margin-top: 2px; + font-weight: 600; + color: #991b1b; + } + + &__approved { + margin-top: 10px; + font-size: 0.8rem; + color: #047857; + } + &__actions { margin-top: 12px; padding-top: 12px; diff --git a/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.ts b/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.ts index 37d66ee..2264ff2 100644 --- a/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.ts +++ b/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.ts @@ -56,6 +56,8 @@ export class MyReimbursementsPageComponent implements OnInit { } submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); } + /** Re-submit a rejected reimbursement after fixing the flagged issue (clears the prior review server-side). */ + resubmit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); } remove(row: ExpenseListItemDto): void { if (!confirm('Delete this reimbursement?')) return; this.api.delete(row.id).subscribe(() => this.load()); @@ -69,10 +71,14 @@ export class MyReimbursementsPageComponent implements OnInit { }); } - /** Editing (and reuploading the photo) is allowed while a reimbursement is still Draft or awaiting review. */ - canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft' || row.status === 'PendingApproval'; } + /** Editing (and reuploading the photo) is allowed while Draft, awaiting review, or after a rejection. */ + canEdit(row: ExpenseListItemDto): boolean { + return row.status === 'Draft' || row.status === 'PendingApproval' || row.status === 'Rejected'; + } /** Submit and Delete only apply before the reimbursement has been submitted. */ isDraft(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; } + /** A rejected reimbursement can be fixed and re-submitted by its owner. */ + isRejected(row: ExpenseListItemDto): boolean { return row.status === 'Rejected'; } statusClass(status: string): string { return ({ Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' } as Record)[status] ?? ''; } diff --git a/APP/src/app/shared/i18n/option-lists.ts b/APP/src/app/shared/i18n/option-lists.ts index f09b9b5..5da3649 100644 --- a/APP/src/app/shared/i18n/option-lists.ts +++ b/APP/src/app/shared/i18n/option-lists.ts @@ -22,6 +22,18 @@ export const EXPENSE_STATUS_OPTIONS: readonly BilingualOption[] = [ { value: 'Rejected', label: 'Rejected/已拒絕' }, ]; +// Expense reject reason templates. For preset reasons the `value` is stored verbatim into +// ReviewNotes; selecting 'Other' switches the dialog to a free-text box. +export const EXPENSE_REJECT_REASON_OPTIONS: readonly BilingualOption[] = [ + { value: 'Receipt unclear, please retake', label: 'Receipt unclear/收據不夠清楚' }, + { value: 'Amount does not match receipt', label: 'Amount mismatch/金額不符' }, + { value: 'Receipt missing', label: 'Receipt missing/缺少收據' }, + { value: 'Wrong category or ministry', label: 'Wrong category/分類錯誤' }, + { value: 'Duplicate submission', label: 'Duplicate/重複申請' }, + { value: 'Needs more information', label: 'Needs more info/需補充說明' }, + { value: 'Other', label: 'Other/其他' }, +]; + export const CHECK_STATUS_OPTIONS: readonly BilingualOption[] = [ { value: 'Issued', label: 'Issued/已開立' }, { value: 'Voided', label: 'Voided/已作廢' },