@@ -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<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),
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<ExpenseLineItemDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -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<string> 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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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}";
|
||||
|
||||
+98
@@ -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>
|
||||
+106
@@ -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;
|
||||
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 {
|
||||
|
||||
@@ -62,19 +62,23 @@
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Status" [width]="140">
|
||||
<kendo-grid-column title="Status" [width]="200">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<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>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Actions" [width]="160">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
||||
<ng-container *ngIf="canApproveOrReject(dataItem)">
|
||||
<button kendoButton themeColor="success" fillMode="flat" (click)="approve(dataItem)">Approve</button>
|
||||
<button kendoButton themeColor="error" fillMode="flat" (click)="openReject(dataItem)">Reject</button>
|
||||
</ng-container>
|
||||
<button *ngIf="canApproveOrReject(dataItem)" kendoButton themeColor="primary" fillMode="flat"
|
||||
(click)="openReview(dataItem)">Review</button>
|
||||
<button *ngIf="canPay(dataItem)" kendoButton themeColor="primary" fillMode="flat"
|
||||
(click)="openPay(dataItem)">Pay</button>
|
||||
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(dataItem.id)"
|
||||
@@ -118,19 +122,10 @@
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
|
||||
<!-- Reject dialog -->
|
||||
<kendo-dialog *ngIf="rejectRow" title="Reject Expense" [width]="400" [maxWidth]="'95vw'" (close)="rejectRow = null">
|
||||
<div class="grid grid-cols-1 gap-3 p-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
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>
|
||||
<!-- Review dialog: detail + receipt preview, with Approve / Reject(reason) -->
|
||||
<app-expense-review-dialog *ngIf="reviewRow" [expenseId]="reviewRow.id"
|
||||
(approve)="onReviewApprove()" (reject)="onReviewReject($event)" (cancel)="closeReview()">
|
||||
</app-expense-review-dialog>
|
||||
|
||||
<!-- Transient save confirmation (sits above the open dialog during continuous entry) -->
|
||||
<div *ngIf="toast" class="save-toast">{{ toast }}</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
+20
-2
@@ -15,15 +15,22 @@
|
||||
</ng-template>
|
||||
</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>
|
||||
<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>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column title="Actions" [width]="200">
|
||||
<kendo-grid-column title="Actions" [width]="230">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<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="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="dataItem.hasReceipt" kendoButton fillMode="flat"
|
||||
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
|
||||
@@ -60,9 +67,20 @@
|
||||
<span [class]="statusClass(row.status)">{{ row.status }}</span>
|
||||
</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">
|
||||
<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="isRejected(row)" kendoButton themeColor="primary" (click)="resubmit(row)">Resubmit</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>
|
||||
</div>
|
||||
|
||||
+40
@@ -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;
|
||||
|
||||
+8
-2
@@ -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<string, string>)[status] ?? '';
|
||||
}
|
||||
|
||||
@@ -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/已作廢' },
|
||||
|
||||
Reference in New Issue
Block a user