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
@@ -7,8 +7,11 @@ using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors; using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services; using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Storage; using ROLAC.API.Services.Storage;
using ROLAC.API.Tests.TestSupport;
using Xunit; using Xunit;
namespace ROLAC.API.Tests.Services; 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); 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<IHttpContextAccessor>();
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), // Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false). // mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId) 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); var got = await svc.OpenReceiptAsync(id, isFinance: true);
Assert.NotNull(got); 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);
}
} }
@@ -0,0 +1,21 @@
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Tests.TestSupport;
/// <summary>Records every audit Write so tests can assert on the emitted actions/summaries.</summary>
public sealed class CapturingAuditLogger : IAuditLogger
{
public readonly record struct Entry(string Action, string Category, string? EntityName, string? EntityId, string? Summary);
public readonly List<Entry> 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));
}
}
+4 -2
View File
@@ -30,15 +30,17 @@ public class ExpenseListItemDto
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
public bool HasReceipt { get; set; } public bool HasReceipt { get; set; }
public string? CheckNumber { 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 class ExpenseDto : ExpenseListItemDto
{ {
public string? Notes { get; set; } public string? Notes { get; set; }
public string? ReviewNotes { get; set; }
public string? SubmittedBy { get; set; } public string? SubmittedBy { get; set; }
public DateTimeOffset? SubmittedAt { get; set; } public DateTimeOffset? SubmittedAt { get; set; }
public DateTimeOffset? ReviewedAt { get; set; }
public DateTimeOffset? PaidAt { get; set; } public DateTimeOffset? PaidAt { get; set; }
public List<ExpenseLineItemDto> Lines { get; set; } = new(); public List<ExpenseLineItemDto> Lines { get; set; } = new();
} }
+2 -1
View File
@@ -53,6 +53,7 @@ public static class AuditActions
public const string CheckIssued = "CheckIssued"; public const string CheckIssued = "CheckIssued";
public const string CheckVoided = "CheckVoided"; public const string CheckVoided = "CheckVoided";
public const string ExpenseApproved = "ExpenseApproved"; public const string ExpenseApproved = "ExpenseApproved";
public const string ExpenseRejected = "ExpenseRejected";
public const string StatementFinalized = "StatementFinalized"; public const string StatementFinalized = "StatementFinalized";
public static readonly IReadOnlyList<string> All = public static readonly IReadOnlyList<string> All =
@@ -60,7 +61,7 @@ public static class AuditActions
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged, Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
PasswordChanged, UserDeactivated, PermissionChanged, PasswordChanged, UserDeactivated, PermissionChanged,
InvitationCreated, InvitationAccepted, CheckIssued, InvitationCreated, InvitationAccepted, CheckIssued,
CheckVoided, ExpenseApproved, StatementFinalized, CheckVoided, ExpenseApproved, ExpenseRejected, StatementFinalized,
]; ];
} }
+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 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)) var memNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}"); .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. // Line count + first line's category, per expense on this page.
var expenseIds = rows.Select(r => r.Id).ToList(); var expenseIds = rows.Select(r => r.Id).ToList();
@@ -111,12 +112,39 @@ public class ExpenseService : IExpenseService
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
HasReceipt = e.ReceiptBlobPath != null, HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber, CheckNumber = e.CheckNumber,
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
ReviewedAt = e.ReviewedAt,
ReviewNotes = e.ReviewNotes,
}; };
}).ToList(); }).ToList();
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; 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) public async Task<ExpenseDto?> GetByIdAsync(int id)
{ {
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == 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() ? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync()
: null; : 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 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 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); 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, VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName,
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes, 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, Lines = lineDtos,
}; };
} }
@@ -225,8 +258,8 @@ public class ExpenseService : IExpenseService
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies. // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
var e = await _db.Expenses.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id) var e = await _db.Expenses.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Expense {id} not found."); ?? throw new KeyNotFoundException($"Expense {id} not found.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval"))) if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements."); 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.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
@@ -258,8 +291,11 @@ public class ExpenseService : IExpenseService
{ {
var e = await RequireAsync(id); var e = await RequireAsync(id);
if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement."); 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; 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(); await _db.SaveChangesAsync();
} }
@@ -282,6 +318,11 @@ public class ExpenseService : IExpenseService
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'."); 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; e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes;
await _db.SaveChangesAsync(); 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) 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) public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
{ {
var e = await RequireAsync(id); var e = await RequireAsync(id);
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval"))) 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 or pending reimbursements."); throw new InvalidOperationException("You can only attach receipts to your own draft, pending, or rejected reimbursements.");
var safe = Path.GetFileName(fileName).Replace(' ', '_'); var safe = Path.GetFileName(fileName).Replace(' ', '_');
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}"; var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
@@ -0,0 +1,98 @@
<kendo-dialog title="Review Expense / 審核支出" (close)="cancel.emit()"
[width]="showReceiptPanel ? 1100 : 620" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<div *ngIf="loading" class="p-6 text-center text-gray-500">Loading… / 載入中…</div>
<div *ngIf="!loading && expense" class="flex flex-col gap-4 md:flex-row">
<!-- Left: read-only expense detail -->
<div class="flex-1 min-w-0 flex flex-col gap-3">
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div class="text-gray-500">Date / 日期</div><div>{{ expense.expenseDate }}</div>
<div class="text-gray-500">Ministry / 事工</div><div>{{ expense.ministryName }}</div>
<div class="text-gray-500">Payee / 收款人</div><div>{{ expense.vendorName || expense.memberName || '—' }}</div>
<div class="text-gray-500">Description / 說明</div><div>{{ expense.description }}</div>
<div class="text-gray-500">Status / 狀態</div><div>{{ expense.status }}</div>
</div>
<!-- Line items -->
<div class="flex flex-col gap-1">
<div class="font-semibold text-sm">明細 / Line Items</div>
<table class="w-full text-sm border-collapse">
<thead>
<tr class="text-left text-gray-500 border-b">
<th class="py-1">Category / 類別</th>
<th class="py-1">Description / 說明</th>
<th class="py-1 text-right">Amount / 金額</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let line of expense.lines" class="border-b border-gray-100">
<td class="py-1">{{ line.categoryGroupName }}<span *ngIf="line.subCategoryName"> / {{ line.subCategoryName }}</span></td>
<td class="py-1 text-gray-600">{{ line.description || '—' }}</td>
<td class="py-1 text-right tabular-nums">{{ line.amount | currency }}</td>
</tr>
</tbody>
<tfoot>
<tr class="font-semibold">
<td class="py-1" colspan="2">Total / 合計</td>
<td class="py-1 text-right tabular-nums">{{ expense.amount | currency }}</td>
</tr>
</tfoot>
</table>
</div>
<!-- Reject reason capture (shown after clicking Reject) -->
<div *ngIf="rejecting" class="flex flex-col gap-2 rounded border border-red-200 bg-red-50 p-3">
<label class="flex flex-col gap-1 text-sm">Reject Reason / 拒絕原因
<kendo-dropdownlist [data]="rejectReasons" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="rejectReason" [defaultItem]="{ value: null, label: '-- Select reason --/請選擇原因' }">
</kendo-dropdownlist>
</label>
<label *ngIf="isOtherReason" class="flex flex-col gap-1 text-sm">Detail / 說明
<kendo-textbox [(ngModel)]="rejectOther" placeholder="Please enter the reason / 請輸入原因"></kendo-textbox>
</label>
</div>
</div>
<!-- Right: receipt preview -->
<div class="md:w-[30rem] md:shrink-0 md:border-l md:pl-4 flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="font-semibold">收據預覽 / Receipt</span>
<div *ngIf="receiptImageUrl" class="flex items-center gap-1">
<button kendoButton size="small" fillMode="flat" (click)="zoomOut()"
[disabled]="receiptZoom <= minZoom" title="縮小 / Zoom out"></button>
<span class="w-12 text-center text-sm tabular-nums">{{ receiptZoom * 100 | number:'1.0-0' }}%</span>
<button kendoButton size="small" fillMode="flat" (click)="zoomIn()"
[disabled]="receiptZoom >= maxZoom" title="放大 / Zoom in"></button>
<button kendoButton size="small" fillMode="flat" (click)="resetZoom()" title="重設 / Reset"></button>
</div>
</div>
<div *ngIf="receiptImageUrl" class="overflow-auto rounded border border-gray-200 bg-gray-50" style="max-height: 72vh;">
<img [src]="receiptImageUrl" alt="Receipt preview" [style.width.%]="receiptZoom * 100" class="block max-w-none" />
</div>
<iframe *ngIf="receiptPdfUrl" [src]="receiptPdfUrl" title="Receipt PDF"
class="w-full rounded border border-gray-200" style="height: 72vh;"></iframe>
<div *ngIf="!showReceiptPanel" class="rounded border border-dashed border-gray-300 p-6 text-center text-gray-400 text-sm">
No receipt attached / 無收據
</div>
</div>
</div>
<kendo-dialog-actions>
<!-- Decision row -->
<ng-container *ngIf="!rejecting">
<button kendoButton (click)="cancel.emit()">Cancel / 取消</button>
<button kendoButton themeColor="error" fillMode="flat" (click)="startReject()">Reject / 拒絕</button>
<button kendoButton themeColor="success" (click)="confirmApprove()">Approve / 核准</button>
</ng-container>
<!-- Reject confirmation row -->
<ng-container *ngIf="rejecting">
<button kendoButton (click)="cancelReject()">Back / 返回</button>
<button kendoButton themeColor="error" [disabled]="!canConfirmReject" (click)="confirmReject()">Confirm Reject / 確認拒絕</button>
</ng-container>
</kendo-dialog-actions>
</kendo-dialog>
@@ -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<void>();
@Output() reject = new EventEmitter<string>(); // emits the composed reviewNotes
@Output() cancel = new EventEmitter<void>();
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);
}
}
@@ -26,10 +26,11 @@ export interface ExpenseListItemDto {
vendorName: string | null; vendorName: string | null;
memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean; memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean;
checkNumber: string | null; checkNumber: string | null;
reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null;
} }
export interface ExpenseDto extends ExpenseListItemDto { export interface ExpenseDto extends ExpenseListItemDto {
notes: string | null; reviewNotes: string | null; notes: string | null;
submittedBy: string | null; submittedAt: string | null; reviewedAt: string | null; paidAt: string | null; submittedBy: string | null; submittedAt: string | null; paidAt: string | null;
lines: ExpenseLineItemDto[]; lines: ExpenseLineItemDto[];
} }
export interface ExpenseLineInput { export interface ExpenseLineInput {
@@ -62,19 +62,23 @@
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
<kendo-grid-column title="Status" [width]="140"> <kendo-grid-column title="Status" [width]="200">
<ng-template kendoGridCellTemplate let-dataItem> <ng-template kendoGridCellTemplate let-dataItem>
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span> <span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
<div *ngIf="dataItem.reviewedByName && (dataItem.status === 'Approved' || dataItem.status === 'Paid')"
class="review-meta">✓ Approved by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}</div>
<div *ngIf="dataItem.reviewedByName && dataItem.status === 'Rejected'" class="review-meta review-meta-reject">
✗ Rejected by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
<div *ngIf="dataItem.reviewNotes" class="review-reason">{{ dataItem.reviewNotes }}</div>
</div>
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
<kendo-grid-column title="Actions" [width]="160"> <kendo-grid-column title="Actions" [width]="160">
<ng-template kendoGridCellTemplate let-dataItem> <ng-template kendoGridCellTemplate let-dataItem>
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button> <button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
<ng-container *ngIf="canApproveOrReject(dataItem)"> <button *ngIf="canApproveOrReject(dataItem)" kendoButton themeColor="primary" fillMode="flat"
<button kendoButton themeColor="success" fillMode="flat" (click)="approve(dataItem)">Approve</button> (click)="openReview(dataItem)">Review</button>
<button kendoButton themeColor="error" fillMode="flat" (click)="openReject(dataItem)">Reject</button>
</ng-container>
<button *ngIf="canPay(dataItem)" kendoButton themeColor="primary" fillMode="flat" <button *ngIf="canPay(dataItem)" kendoButton themeColor="primary" fillMode="flat"
(click)="openPay(dataItem)">Pay</button> (click)="openPay(dataItem)">Pay</button>
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(dataItem.id)" <button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(dataItem.id)"
@@ -118,19 +122,10 @@
</kendo-dialog-actions> </kendo-dialog-actions>
</kendo-dialog> </kendo-dialog>
<!-- Reject dialog --> <!-- Review dialog: detail + receipt preview, with Approve / Reject(reason) -->
<kendo-dialog *ngIf="rejectRow" title="Reject Expense" [width]="400" [maxWidth]="'95vw'" (close)="rejectRow = null"> <app-expense-review-dialog *ngIf="reviewRow" [expenseId]="reviewRow.id"
<div class="grid grid-cols-1 gap-3 p-2"> (approve)="onReviewApprove()" (reject)="onReviewReject($event)" (cancel)="closeReview()">
<label class="flex flex-col gap-1"> </app-expense-review-dialog>
Review Notes
<kendo-textbox [(ngModel)]="rejectNotes" placeholder="Optional notes for submitter"></kendo-textbox>
</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="rejectRow = null">Cancel</button>
<button kendoButton themeColor="error" (click)="confirmReject()">Reject</button>
</kendo-dialog-actions>
</kendo-dialog>
<!-- Transient save confirmation (sits above the open dialog during continuous entry) --> <!-- Transient save confirmation (sits above the open dialog during continuous entry) -->
<div *ngIf="toast" class="save-toast">{{ toast }}</div> <div *ngIf="toast" class="save-toast">{{ toast }}</div>
@@ -45,6 +45,24 @@
text-decoration: underline; 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 // Save confirmation pill. z-index sits above the Kendo dialog overlay so it
// stays visible while the continuous-entry dialog remains open. // stays visible while the continuous-entry dialog remains open.
.save-toast { .save-toast {
@@ -11,6 +11,7 @@ import { EXPENSE_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service'; import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service';
import { MinistryApiService } from '../../services/ministry-api.service'; import { MinistryApiService } from '../../services/ministry-api.service';
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component'; 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 { ExpenseDto, ExpenseListItemDto, MinistryDto } from '../../models/expense.model';
import { switchMap, of } from 'rxjs'; import { switchMap, of } from 'rxjs';
@@ -20,6 +21,7 @@ import { switchMap, of } from 'rxjs';
imports: [ imports: [
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule, CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
InputsModule, DialogsModule, DateInputsModule, ExpenseFormDialogComponent, InputsModule, DialogsModule, DateInputsModule, ExpenseFormDialogComponent,
ExpenseReviewDialogComponent,
], ],
templateUrl: './expenses-page.component.html', templateUrl: './expenses-page.component.html',
styleUrls: ['./expenses-page.component.scss'], styleUrls: ['./expenses-page.component.scss'],
@@ -46,8 +48,8 @@ export class ExpensesPageComponent implements OnInit {
payCheckNumber = ''; payCheckNumber = '';
payDate = new Date(); payDate = new Date();
rejectRow: ExpenseListItemDto | null = null; /** Row whose detail+receipt are open in the review dialog for an approve/reject decision. */
rejectNotes = ''; reviewRow: ExpenseListItemDto | null = null;
/** Transient confirmation pill, used so the user gets feedback during continuous entry. */ /** Transient confirmation pill, used so the user gets feedback during continuous entry. */
toast: string | null = null; toast: string | null = null;
@@ -110,19 +112,18 @@ export class ExpensesPageComponent implements OnInit {
).subscribe(() => { this.closeEdit(); this.load(); }); ).subscribe(() => { this.closeEdit(); this.load(); });
} }
approve(row: ExpenseListItemDto): void { openReview(row: ExpenseListItemDto): void { this.reviewRow = row; }
this.api.approve(row.id).subscribe(() => this.load()); 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 { onReviewReject(reviewNotes: string): void {
this.rejectRow = row; if (!this.reviewRow) return;
this.rejectNotes = ''; this.api.reject(this.reviewRow.id, { reviewNotes: reviewNotes || null }).subscribe(() => {
} this.reviewRow = null;
confirmReject(): void {
if (!this.rejectRow) return;
this.api.reject(this.rejectRow.id, { reviewNotes: this.rejectNotes || null }).subscribe(() => {
this.rejectRow = null;
this.load(); this.load();
}); });
} }
@@ -15,15 +15,22 @@
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column> <kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column title="Status" [width]="140"> <kendo-grid-column title="Status" [width]="220">
<ng-template kendoGridCellTemplate let-dataItem> <ng-template kendoGridCellTemplate let-dataItem>
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span> <span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
<div *ngIf="dataItem.reviewedByName && (dataItem.status === 'Approved' || dataItem.status === 'Paid')"
class="review-meta">✓ Approved by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}</div>
<div *ngIf="dataItem.status === 'Rejected'" class="review-meta review-meta-reject">
✗ Rejected<span *ngIf="dataItem.reviewedByName"> by {{ dataItem.reviewedByName }}</span><br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
<div *ngIf="dataItem.reviewNotes" class="review-reason">{{ dataItem.reviewNotes }}</div>
</div>
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
<kendo-grid-column title="Actions" [width]="200"> <kendo-grid-column title="Actions" [width]="230">
<ng-template kendoGridCellTemplate let-dataItem> <ng-template kendoGridCellTemplate let-dataItem>
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button> <button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
<button *ngIf="isDraft(dataItem)" kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button> <button *ngIf="isDraft(dataItem)" kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
<button *ngIf="isRejected(dataItem)" kendoButton themeColor="primary" fillMode="flat" (click)="resubmit(dataItem)">Resubmit</button>
<button *ngIf="isDraft(dataItem)" kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button> <button *ngIf="isDraft(dataItem)" kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat" <button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat"
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button> (click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
@@ -60,9 +67,20 @@
<span [class]="statusClass(row.status)">{{ row.status }}</span> <span [class]="statusClass(row.status)">{{ row.status }}</span>
</div> </div>
<!-- Rejection feedback: what the submitter must fix before resubmitting -->
<div *ngIf="row.status === 'Rejected'" class="rmb-card__reject">
✗ Rejected<span *ngIf="row.reviewedByName"> by {{ row.reviewedByName }}</span>
<span *ngIf="row.reviewedAt"> · {{ row.reviewedAt | date:'yyyy-MM-dd HH:mm' }}</span>
<div *ngIf="row.reviewNotes" class="rmb-card__reject-reason">{{ row.reviewNotes }}</div>
</div>
<div *ngIf="row.reviewedByName && (row.status === 'Approved' || row.status === 'Paid')" class="rmb-card__approved">
✓ Approved by {{ row.reviewedByName }} · {{ row.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
</div>
<div class="rmb-card__actions" *ngIf="canEdit(row) || row.hasReceipt"> <div class="rmb-card__actions" *ngIf="canEdit(row) || row.hasReceipt">
<button *ngIf="canEdit(row)" kendoButton fillMode="outline" (click)="openEdit(row)">Edit</button> <button *ngIf="canEdit(row)" kendoButton fillMode="outline" (click)="openEdit(row)">Edit</button>
<button *ngIf="isDraft(row)" kendoButton themeColor="primary" (click)="submit(row)">Submit</button> <button *ngIf="isDraft(row)" kendoButton themeColor="primary" (click)="submit(row)">Submit</button>
<button *ngIf="isRejected(row)" kendoButton themeColor="primary" (click)="resubmit(row)">Resubmit</button>
<button *ngIf="isDraft(row)" kendoButton fillMode="outline" (click)="remove(row)">Delete</button> <button *ngIf="isDraft(row)" kendoButton fillMode="outline" (click)="remove(row)">Delete</button>
<button *ngIf="row.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(row.id)">Receipt</button> <button *ngIf="row.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(row.id)">Receipt</button>
</div> </div>
@@ -45,6 +45,24 @@
text-decoration: underline; 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 // Mobile card list
// NOTE: display/flex layout lives on the element via Tailwind (flex flex-col gap-3) // 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` // so the responsive `md:hidden` utility wins on desktop. Setting `display: flex`
@@ -116,6 +134,28 @@
margin-top: 12px; 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 { &__actions {
margin-top: 12px; margin-top: 12px;
padding-top: 12px; padding-top: 12px;
@@ -56,6 +56,8 @@ export class MyReimbursementsPageComponent implements OnInit {
} }
submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); } 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 { remove(row: ExpenseListItemDto): void {
if (!confirm('Delete this reimbursement?')) return; if (!confirm('Delete this reimbursement?')) return;
this.api.delete(row.id).subscribe(() => this.load()); 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. */ /** 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'; } 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. */ /** Submit and Delete only apply before the reimbursement has been submitted. */
isDraft(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; } 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 { statusClass(status: string): string {
return ({ Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' } as Record<string, string>)[status] ?? ''; return ({ Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' } as Record<string, string>)[status] ?? '';
} }
+12
View File
@@ -22,6 +22,18 @@ export const EXPENSE_STATUS_OPTIONS: readonly BilingualOption[] = [
{ value: 'Rejected', label: 'Rejected/已拒絕' }, { 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[] = [ export const CHECK_STATUS_OPTIONS: readonly BilingualOption[] = [
{ value: 'Issued', label: 'Issued/已開立' }, { value: 'Issued', label: 'Issued/已開立' },
{ value: 'Voided', label: 'Voided/已作廢' }, { value: 'Voided', label: 'Voided/已作廢' },