Compare commits

2 Commits

Author SHA1 Message Date
Chris Chen 4b949dff9b 更新支票列印
ci-cd-vm / ci-cd (push) Successful in 1m56s
2026-06-27 21:37:40 -07:00
Chris Chen 773d38d838 update view.
ci-cd-vm / ci-cd (push) Successful in 1m59s
2026-06-25 21:55:16 -07:00
6 changed files with 402 additions and 129 deletions
+3
View File
@@ -168,6 +168,9 @@ builder.Services.AddScoped<ISettingsService, SettingsService>();
builder.Services.AddScoped<IDisbursementService, DisbursementService>(); builder.Services.AddScoped<IDisbursementService, DisbursementService>();
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService, builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>(); ROLAC.API.Services.Disbursement.CheckPrintService>();
// Pre-printed check-stock field coordinates; tune in appsettings.json without recompiling.
builder.Services.Configure<ROLAC.API.Services.Disbursement.CheckPrintLayoutOptions>(
config.GetSection("CheckPrint:Layout"));
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>(); builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
// ── Notifications (email via SMTP + Line) ────────────────────────────────── // ── Notifications (email via SMTP + Line) ──────────────────────────────────
@@ -0,0 +1,86 @@
namespace ROLAC.API.Services.Disbursement;
/// <summary>
/// Field coordinates (in inches) for printing onto pre-printed three-stub check stock.
/// Every position is bound from the "CheckPrint:Layout" section of appsettings.json so alignment
/// can be tuned and reloaded without recompiling. Positions are expressed as an X (absolute from the
/// page's left edge) plus a Y offset within the field's stub; the per-stub origins below place the
/// three stacked regions (check + two identical receipt copies) down the 8.5"x11" page.
/// </summary>
public sealed class CheckPrintLayoutOptions
{
// Calibration nudge (inches): a TextBox renders its text inset down-and-right from the box
// origin by a fixed internal margin + line leading, so configured X/Y don't match the ink.
// These values are subtracted from every position so configured X/Y == actual printed position.
// To recalibrate: set both to 0, print, measure how far the ink sits past a known X/Y, and put
// those differences here (defaults are the measured inset for this stock).
public float TextInsetX { get; set; } = 0.13f;
public float TextInsetY { get; set; } = 0.15f;
// Stub origins — top edge of each region, inches from the top of the page.
public float CheckOriginY { get; set; } = 0f;
public float Receipt1OriginY { get; set; } = 3.67f;
public float Receipt2OriginY { get; set; } = 7.33f;
// Check stub fields (offset within the check stub).
public FieldPos Payee { get; set; } = new() { X = 1.25f, OffsetY = 1.75f, FontSize = 11, Bold = true };
public FieldPos AmountNumeric { get; set; } = new() { X = 6.50f, OffsetY = 1.75f, FontSize = 11, Bold = true };
public FieldPos AmountWords { get; set; } = new() { X = 0.60f, OffsetY = 2.20f, FontSize = 10 };
public FieldPos Memo { get; set; } = new() { X = 0.60f, OffsetY = 2.90f, FontSize = 9 };
public FieldPos CheckDate { get; set; } = new() { X = 6.50f, OffsetY = 1.25f, FontSize = 10 };
// Receipt stub fields (offset within a receipt stub — shared by both identical copies).
public FieldPos ReceiptPayee { get; set; } = new() { X = 1.00f, OffsetY = 0.30f, FontSize = 10, Bold = true };
public FieldPos ReceiptAmount { get; set; } = new() { X = 6.50f, OffsetY = 0.30f, FontSize = 10, Bold = true };
public FieldPos ReceiptMemo { get; set; } = new() { X = 1.00f, OffsetY = 0.60f, FontSize = 9 };
public FieldPos ReceiptDate { get; set; } = new() { X = 6.50f, OffsetY = 0.60f, FontSize = 9 };
// Voucher detail grid (offsets within a receipt stub).
public VoucherGridOptions Grid { get; set; } = new();
}
/// <summary>One printable field: where it sits and how it renders.</summary>
public sealed class FieldPos
{
/// <summary>Absolute X from the page's left edge, in inches.</summary>
public float X { get; set; }
/// <summary>Y offset within the field's stub, in inches (added to the stub origin).</summary>
public float OffsetY { get; set; }
public float FontSize { get; set; } = 10;
public bool Bold { get; set; }
}
/// <summary>
/// Two-column voucher detail grid on each receipt stub: 6 rows per column, 12 expense lines max,
/// filled column-major (lines 1-6 left, 7-12 right). All offsets are within the receipt stub.
/// </summary>
public sealed class VoucherGridOptions
{
/// <summary>Left edge of the left column block, in inches from the page's left edge.</summary>
public float OriginX { get; set; } = 0.60f;
/// <summary>Y offset (within the stub) of the first data row.</summary>
public float OffsetY { get; set; } = 1.10f;
public float RowHeight { get; set; } = 0.22f;
/// <summary>Horizontal gap between the left and right column blocks, in inches.</summary>
public float ColumnGap { get; set; } = 0.30f;
public float DateWidth { get; set; } = 0.85f;
public float DescWidth { get; set; } = 2.10f;
public float AmountWidth { get; set; } = 0.80f;
/// <summary>Draw our own Date/Description/Amount headers. Set false if the stock pre-prints them.</summary>
public bool ShowGridHeaders { get; set; } = true;
/// <summary>Y offset (within the stub) of the header row.</summary>
public float HeaderOffsetY { get; set; } = 0.88f;
/// <summary>Y offset (within the stub) of the "...and N more lines" overflow hint.</summary>
public float OverflowOffsetY { get; set; } = 2.55f;
public float FontSize { get; set; } = 8.5f;
}
@@ -1,17 +1,28 @@
using System.Drawing;
using System.Globalization; using System.Globalization;
using DevExpress.Office; using DevExpress.Office;
using DevExpress.XtraRichEdit; using DevExpress.XtraRichEdit;
using DevExpress.XtraRichEdit.API.Native; using DevExpress.XtraRichEdit.API.Native;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Services.Disbursement; namespace ROLAC.API.Services.Disbursement;
/// <summary> /// <summary>
/// Renders a check on 8.5"x11" stock using the DevExpress Office (RichEdit) API: /// Renders a check onto pre-printed 8.5"x11" three-stub stock using the DevExpress Office
/// a check block on top followed by two identical ledger detail stubs. The layout is /// (RichEdit) API. Fields are placed as absolutely-positioned floating TextBoxes so they align to
/// built programmatically (no external .docx template) and exported to PDF. /// the boxes printed on the stock; every coordinate comes from <see cref="CheckPrintLayoutOptions"/>
/// (bound from "CheckPrint:Layout" in appsettings.json) so alignment can be tuned without recompiling.
/// The page is one check stub on top followed by two identical receipt copies.
/// </summary> /// </summary>
public class CheckPrintService : ICheckPrintService public class CheckPrintService : ICheckPrintService
{ {
private readonly CheckPrintLayoutOptions _layout;
public CheckPrintService(IOptions<CheckPrintLayoutOptions> layout)
{
_layout = layout.Value;
}
public Task<Stream> RenderPdfAsync(CheckPrintModel model) public Task<Stream> RenderPdfAsync(CheckPrintModel model)
{ {
using var server = new RichEditDocumentServer(); using var server = new RichEditDocumentServer();
@@ -26,9 +37,13 @@ public class CheckPrintService : ICheckPrintService
section.Margins.Left = section.Margins.Right = 0.6f; section.Margins.Left = section.Margins.Right = 0.6f;
section.Margins.Top = section.Margins.Bottom = 0.5f; section.Margins.Top = section.Margins.Bottom = 0.5f;
BuildCheckBlock(doc, model); // Floating TextBoxes must anchor to a paragraph; everything is positioned relative to
BuildStub(doc, model, "PAYMENT ADVICE — DETAIL"); // the page, so a single empty anchor paragraph at the document start is enough.
BuildStub(doc, model, "PAYMENT ADVICE — RECORD COPY"); var anchor = doc.Paragraphs[0];
BuildCheckStub(doc, anchor, model);
BuildReceiptStub(doc, anchor, model, _layout.Receipt1OriginY);
BuildReceiptStub(doc, anchor, model, _layout.Receipt2OriginY);
} }
finally finally
{ {
@@ -41,6 +56,125 @@ public class CheckPrintService : ICheckPrintService
return Task.FromResult<Stream>(ms); return Task.FromResult<Stream>(ms);
} }
// ── check page builders (absolute positioning) ─────────────────────────────
private void BuildCheckStub(Document doc, Paragraph anchor, CheckPrintModel model)
{
var check = model.Check;
var originY = _layout.CheckOriginY;
PlaceField(doc, anchor, _layout.Payee, originY, 4.5f, check.PayeeName);
PlaceField(doc, anchor, _layout.AmountNumeric, originY, 1.6f, FormatCurrency(check.Amount,13), rightAlign: false);
PlaceField(doc, anchor, _layout.AmountWords, originY, 6.0f, model.AmountInWords);
PlaceField(doc, anchor, _layout.CheckDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy"));
if (!string.IsNullOrWhiteSpace(check.Memo))
PlaceField(doc, anchor, _layout.Memo, originY, 4.5f, check.Memo);
}
private void BuildReceiptStub(Document doc, Paragraph anchor, CheckPrintModel model, float originY)
{
var check = model.Check;
PlaceField(doc, anchor, _layout.ReceiptPayee, originY, 4.5f, "Pay To The Order Of: " + check.PayeeName);
PlaceField(doc, anchor, _layout.ReceiptAmount, originY, 1.6f, "Amount: " + FormatCurrency(check.Amount), rightAlign: true);
PlaceField(doc, anchor, _layout.ReceiptDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy"));
if (!string.IsNullOrWhiteSpace(check.Memo))
PlaceField(doc, anchor, _layout.ReceiptMemo, originY, 4.5f, check.Memo);
BuildVoucherGrid(doc, anchor, model, originY);
}
/// <summary>
/// Two-column voucher detail grid: up to 12 expense lines (6 per column), filled column-major.
/// Beyond 12 lines, prints the first 12 plus an overflow hint so the receipt total stays honest.
/// </summary>
private void BuildVoucherGrid(Document doc, Paragraph anchor, CheckPrintModel model, float originY)
{
var grid = _layout.Grid;
var blockWidth = grid.DateWidth + grid.DescWidth + grid.AmountWidth;
const int rowsPerColumn = 6;
const int maxLines = rowsPerColumn * 2;
float ColumnX(int column) => grid.OriginX + column * (blockWidth + grid.ColumnGap);
void PlaceRow(float x, float y, string date, string description, string amount, bool bold)
{
PlaceText(doc, anchor, x, y, grid.DateWidth, date, grid.FontSize, bold, rightAlign: false);
PlaceText(doc, anchor, x + grid.DateWidth, y, grid.DescWidth, description, grid.FontSize, bold, rightAlign: false);
PlaceText(doc, anchor, x + grid.DateWidth + grid.DescWidth, y, grid.AmountWidth, amount, grid.FontSize, bold, rightAlign: true);
}
if (grid.ShowGridHeaders)
{
var headerY = originY + grid.HeaderOffsetY;
for (var column = 0; column < 2; column++)
PlaceRow(ColumnX(column), headerY, "Date", "Description", "Amount", bold: true);
}
var printed = Math.Min(model.Lines.Count, maxLines);
for (var i = 0; i < printed; i++)
{
var line = model.Lines[i];
var column = i < rowsPerColumn ? 0 : 1;
var row = i % rowsPerColumn;
var y = originY + grid.OffsetY + row * grid.RowHeight;
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
PlaceRow(ColumnX(column), y, date, line.Description, FormatCurrency(line.Amount), bold: false);
}
if (model.Lines.Count > maxLines)
{
var remaining = model.Lines.Count - maxLines;
var remainingTotal = model.Lines.Skip(maxLines).Sum(line => line.Amount);
var hint = $"…and {remaining} more line(s) ({FormatCurrency(remainingTotal)})";
PlaceText(doc, anchor, grid.OriginX, originY + grid.OverflowOffsetY,
blockWidth * 2 + grid.ColumnGap, hint, grid.FontSize, bold: false, rightAlign: false);
}
}
/// <summary>Places one configured field's value at its stub-relative position.</summary>
private void PlaceField(Document doc, Paragraph anchor, FieldPos field, float stubOriginY,
float width, string text, bool rightAlign = false)
{
PlaceText(doc, anchor, field.X, stubOriginY + field.OffsetY, width, text,
field.FontSize, field.Bold, rightAlign);
}
/// <summary>
/// Inserts a borderless, fill-less floating TextBox positioned absolutely relative to the page
/// (X from the left edge, Y from the top edge) and writes <paramref name="text"/> into it.
/// </summary>
private void PlaceText(Document doc, Paragraph anchor, float x, float y, float width,
string text, float fontSize, bool bold, bool rightAlign)
{
var shape = doc.Shapes.InsertTextBox(anchor.Range.Start);
shape.HorizontalAlignment = ShapeHorizontalAlignment.None;
shape.VerticalAlignment = ShapeVerticalAlignment.None;
shape.RelativeHorizontalPosition = ShapeRelativeHorizontalPosition.Page;
shape.RelativeVerticalPosition = ShapeRelativeVerticalPosition.Page;
// Pull the box up-and-left by the calibration inset so the text inside lands exactly on the
// configured (x, y) rather than down-and-right of it.
shape.Offset = new PointF(x - _layout.TextInsetX, y - _layout.TextInsetY);
shape.Size = new SizeF(width, Math.Max(0.2f, fontSize / 72f + 0.08f));
shape.Fill.SetNoFill();
shape.Line.Fill.SetNoFill();
var body = shape.ShapeFormat.TextBox.Document;
var range = body.InsertText(body.Range.Start, text);
var characters = body.BeginUpdateCharacters(range);
characters.FontSize = fontSize;
characters.Bold = bold;
body.EndUpdateCharacters(characters);
if (rightAlign)
{
var paragraphs = body.BeginUpdateParagraphs(range);
paragraphs.Alignment = ParagraphAlignment.Right;
body.EndUpdateParagraphs(paragraphs);
}
}
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model) public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
{ {
using var server = new RichEditDocumentServer(); using var server = new RichEditDocumentServer();
@@ -131,110 +265,15 @@ public class CheckPrintService : ICheckPrintService
private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? ""); private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? "");
private static void BuildCheckBlock(Document doc, CheckPrintModel m)
{
var issuer = m.Issuer;
var check = m.Check;
AppendLine(doc, issuer.Name, bold: true, size: 13);
var issuerAddr = JoinAddress(issuer.Address, issuer.City, issuer.State, issuer.ZipCode);
if (!string.IsNullOrWhiteSpace(issuerAddr)) AppendLine(doc, issuerAddr, size: 9);
if (!string.IsNullOrWhiteSpace(issuer.BankName)) AppendLine(doc, issuer.BankName, size: 9);
AppendLine(doc, "");
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy}", bold: true, size: 10);
AppendLine(doc, "");
AppendLine(doc, $"PAY TO THE ORDER OF: {check.PayeeName}", bold: true, size: 11);
var payeeAddr = JoinAddress(check.PayeeAddress, check.PayeeCity, check.PayeeState, check.PayeeZip);
if (!string.IsNullOrWhiteSpace(payeeAddr)) AppendLine(doc, payeeAddr, size: 9);
AppendLine(doc, "");
AppendLine(doc, $"AMOUNT: {FormatCurrency(check.Amount)}", bold: true, size: 12);
AppendLine(doc, m.AmountInWords, size: 10);
if (!string.IsNullOrWhiteSpace(check.Memo)) { AppendLine(doc, ""); AppendLine(doc, $"Memo: {check.Memo}", size: 9); }
AppendLine(doc, "");
AppendLine(doc, "____________________________________", size: 10);
AppendLine(doc, "Authorized Signature", size: 8);
AppendSeparator(doc);
}
private static void BuildStub(Document doc, CheckPrintModel m, string title)
{
var check = m.Check;
AppendLine(doc, title, bold: true, size: 10);
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy} Payee: {check.PayeeName}", size: 9);
AppendLine(doc, "");
var rows = m.Lines.Count + 2; // header + lines + total
var table = doc.Tables.Create(doc.Range.End, rows, 3, AutoFitBehaviorType.AutoFitToWindow);
table.Borders.InsideHorizontalBorder.LineStyle = TableBorderLineStyle.Single;
table.Borders.Top.LineStyle = table.Borders.Bottom.LineStyle = TableBorderLineStyle.Single;
SetCell(doc, table[0, 0], "Date", bold: true);
SetCell(doc, table[0, 1], "Description", bold: true);
SetCell(doc, table[0, 2], "Amount", bold: true, right: true);
for (var i = 0; i < m.Lines.Count; i++)
{
var line = m.Lines[i];
var r = i + 1;
// CheckLine snapshots description; date comes from the source expense if loaded.
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
SetCell(doc, table[r, 0], date);
SetCell(doc, table[r, 1], line.Description);
SetCell(doc, table[r, 2], FormatCurrency(line.Amount), right: true);
}
var totalRow = rows - 1;
SetCell(doc, table[totalRow, 0], "");
SetCell(doc, table[totalRow, 1], "TOTAL", bold: true, right: true);
SetCell(doc, table[totalRow, 2], FormatCurrency(check.Amount), bold: true, right: true);
AppendLine(doc, "");
if (check.ReceiptSignedAt is { } signedAt)
AppendLine(doc, $"Received by: {check.ReceiptSignedName} on {signedAt:MM/dd/yyyy HH:mm}", size: 9);
AppendSeparator(doc);
}
// ── low-level helpers ────────────────────────────────────────────────────── // ── low-level helpers ──────────────────────────────────────────────────────
private static string FormatCurrency(decimal amount, int paddingAstric = 0)
private static void AppendLine(Document doc, string text, bool bold = false, float size = 10)
{ {
var range = doc.AppendText(text + "\r\n"); var c = (CultureInfo)CultureInfo.GetCultureInfo("en-US").Clone();
var cp = doc.BeginUpdateCharacters(range); c.NumberFormat.CurrencySymbol = "";
cp.Bold = bold; string formatedAmount = amount.ToString("#,##0.00", CultureInfo.GetCultureInfo("en-US"));
cp.FontSize = size; return paddingAstric > 0 ? formatedAmount.PadLeft(paddingAstric, '*') : formatedAmount;
doc.EndUpdateCharacters(cp);
} }
private static void AppendSeparator(Document doc)
{
AppendLine(doc, "");
AppendLine(doc, "------------------------------------------------------------------------------------------", size: 8);
AppendLine(doc, "");
}
private static void SetCell(Document doc, TableCell cell, string text, bool bold = false, bool right = false)
{
var range = doc.InsertText(cell.ContentRange.Start, text);
var cp = doc.BeginUpdateCharacters(range);
cp.Bold = bold;
cp.FontSize = 9;
doc.EndUpdateCharacters(cp);
if (right)
{
var pp = doc.BeginUpdateParagraphs(range);
pp.Alignment = ParagraphAlignment.Right;
doc.EndUpdateParagraphs(pp);
}
}
private static string FormatCurrency(decimal amount) =>
amount.ToString("C2", CultureInfo.GetCultureInfo("en-US"));
private static string JoinAddress(string? addr, string? city, string? state, string? zip) private static string JoinAddress(string? addr, string? city, string? state, string? zip)
{ {
var cityLine = string.Join(", ", var cityLine = string.Join(", ",
+33
View File
@@ -55,5 +55,38 @@
}, },
"Ai": { "Ai": {
"Provider": "Claude" "Provider": "Claude"
},
"CheckPrint": {
"//": "Field coordinates (inches) for pre-printed three-stub check stock. X = from page left edge; OffsetY = within the field's stub, added to the stub origin. Tune to match your stock, then restart — no recompile.",
"Layout": {
"//cal": "TextInset* compensates for the fixed inset the TextBox adds around its text so configured X/Y == actual ink. To recalibrate, set both to 0, print, measure the drift past a known X/Y, and enter the differences here.",
"TextInsetX": 0.13,
"TextInsetY": 0.15,
"CheckOriginY": 0.0,
"Receipt1OriginY": 3.67,
"Receipt2OriginY": 7.33,
"Payee": { "X": 1.1, "OffsetY": 1.35, "FontSize": 11, "Bold": true },
"AmountNumeric": { "X": 7.0, "OffsetY": 1.35, "FontSize": 12, "Bold": true },
"AmountWords": { "X": 0.30, "OffsetY": 1.67, "FontSize": 10 },
"Memo": { "X": 0.60, "OffsetY": 2.85, "FontSize": 9 },
"CheckDate": { "X": 7.00, "OffsetY": 0.90, "FontSize": 10 },
"ReceiptPayee": { "X": 1.00, "OffsetY": 0.30, "FontSize": 10, "Bold": true },
"ReceiptAmount": { "X": 6.50, "OffsetY": 0.30, "FontSize": 10, "Bold": true },
"ReceiptMemo": { "X": 1.00, "OffsetY": 0.60, "FontSize": 9 },
"ReceiptDate": { "X": 6.50, "OffsetY": 0.60, "FontSize": 9 },
"Grid": {
"OriginX": 0.60,
"OffsetY": 1.10,
"RowHeight": 0.22,
"ColumnGap": 0.30,
"DateWidth": 0.85,
"DescWidth": 2.10,
"AmountWidth": 0.80,
"ShowGridHeaders": true,
"HeaderOffsetY": 0.88,
"OverflowOffsetY": 2.55,
"FontSize": 8.5
}
}
} }
} }
@@ -30,20 +30,24 @@
</div> </div>
</div> </div>
<!-- Main grid --> <!-- Desktop grid -->
<div class="hidden md:block">
<div class="hint-text-sm mb-2">Right-click a row for actions / 右鍵顯示動作</div>
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip" <kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
[pageSize]="pageSize" (pageChange)="onPageChange($event)"> [pageSize]="pageSize" (pageChange)="onPageChange($event)" (cellClick)="onCellClick($event)">
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column> <kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
<!-- <kendo-grid-column field="type" title="Type" [width]="140"></kendo-grid-column> --> <!-- <kendo-grid-column field="type" title="Type" [width]="140"></kendo-grid-column> -->
<kendo-grid-column field="ministryName" title="Ministry" [width]="280"></kendo-grid-column> <kendo-grid-column title="Ministry / Category" [width]="240">
<kendo-grid-column title="Category" [width]="360">
<ng-template kendoGridCellTemplate let-dataItem> <ng-template kendoGridCellTemplate let-dataItem>
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1" class="text-gray-500"> +{{ dataItem.lineCount - 1 }}</span> <div>{{ dataItem.ministryName }}</div>
<div class="text-gray-500 text-xs">
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1"> +{{ dataItem.lineCount - 1 }}</span>
</div>
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>
@@ -81,19 +85,75 @@
</ng-template> </ng-template>
</kendo-grid-column> </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>
<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)"
class="receipt-link">Receipt</button>
</ng-template>
</kendo-grid-column>
</kendo-grid> </kendo-grid>
<kendo-contextmenu #rowMenu [items]="rowMenuItems" (select)="onRowMenuSelect($event)"></kendo-contextmenu>
</div>
<!-- Mobile cards -->
<div class="md:hidden flex flex-col gap-3">
<div *ngFor="let dataItem of rows" class="rounded border p-3 flex flex-col gap-2">
<div class="flex justify-between items-start gap-2">
<div class="text-sm text-gray-500">{{ dataItem.expenseDate }}</div>
<div class="font-semibold">{{ dataItem.amount | currency }}</div>
</div>
<div>
<div class="font-medium">{{ dataItem.ministryName }}</div>
<div class="text-gray-500 text-xs">
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1"> +{{ dataItem.lineCount - 1 }}</span>
</div>
</div>
<div *ngIf="dataItem.description" class="text-sm">{{ dataItem.description }}</div>
<div class="text-sm flex justify-between gap-2">
<span class="text-gray-500">Payee / 收款人</span>
<span class="text-right">
<ng-container *ngIf="dataItem.vendorName; else mobileMemberPayee">{{ dataItem.vendorName }}</ng-container>
<ng-template #mobileMemberPayee>
<ng-container *ngIf="dataItem.memberName; else mobileDash">
<span *ngIf="dataItem.memberNickName">{{ dataItem.memberNickName }} </span>
<span [class.text-gray-500]="dataItem.memberNickName">{{ dataItem.memberName }}</span>
</ng-container>
<ng-template #mobileDash></ng-template>
</ng-template>
</span>
</div>
<div *ngIf="dataItem.status === 'Paid' && dataItem.checkNumber" class="text-sm flex justify-between gap-2">
<span class="text-gray-500">Check # / 支票號</span>
<span>{{ dataItem.checkNumber }}</span>
</div>
<div>
<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>
</div>
<div class="flex flex-wrap gap-2 pt-1">
<button *ngIf="canEdit(dataItem)" kendoButton size="small" (click)="openEdit(dataItem)">Edit</button>
<button *ngIf="canApproveOrReject(dataItem)" kendoButton size="small" themeColor="primary"
(click)="openReview(dataItem)">Review</button>
<button *ngIf="canPay(dataItem)" kendoButton size="small" themeColor="primary"
(click)="openPay(dataItem)">Pay</button>
<button *ngIf="dataItem.hasReceipt" kendoButton size="small" fillMode="outline"
(click)="openReceipt(dataItem.id)">Receipt</button>
</div>
</div>
<div *ngIf="!loading && rows.length === 0" class="text-center text-gray-500 py-6">No expenses / 無支出資料</div>
<div *ngIf="rows.length > 0" class="flex items-center justify-between gap-2 pt-1">
<button kendoButton size="small" [disabled]="page <= 1" (click)="prevPage()"> Prev</button>
<span class="text-sm text-gray-500">{{ page }} / {{ totalPages }}</span>
<button kendoButton size="small" [disabled]="page >= totalPages" (click)="nextPage()">Next </button>
</div>
</div>
<!-- Vendor Payment dialog --> <!-- Vendor Payment dialog -->
<app-expense-form-dialog *ngIf="vendorDialogOpen" mode="vendor" title="Vendor Payment" (save)="onVendorSave($event)" <app-expense-form-dialog *ngIf="vendorDialogOpen" mode="vendor" title="Vendor Payment" (save)="onVendorSave($event)"
@@ -1,12 +1,13 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid'; import { GridModule, PageChangeEvent, CellClickEvent } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { InputsModule } from '@progress/kendo-angular-inputs'; import { InputsModule } from '@progress/kendo-angular-inputs';
import { DialogsModule } from '@progress/kendo-angular-dialog'; import { DialogsModule } from '@progress/kendo-angular-dialog';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs'; import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { EXPENSE_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists'; 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';
@@ -20,8 +21,8 @@ import { switchMap, of } from 'rxjs';
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule, CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
InputsModule, DialogsModule, DateInputsModule, ExpenseFormDialogComponent, InputsModule, DialogsModule, DateInputsModule, ContextMenuModule,
ExpenseReviewDialogComponent, ExpenseFormDialogComponent, ExpenseReviewDialogComponent,
], ],
templateUrl: './expenses-page.component.html', templateUrl: './expenses-page.component.html',
styleUrls: ['./expenses-page.component.scss'], styleUrls: ['./expenses-page.component.scss'],
@@ -51,6 +52,11 @@ export class ExpensesPageComponent implements OnInit {
/** Row whose detail+receipt are open in the review dialog for an approve/reject decision. */ /** Row whose detail+receipt are open in the review dialog for an approve/reject decision. */
reviewRow: ExpenseListItemDto | null = null; reviewRow: ExpenseListItemDto | null = null;
/** Right-click row-action menu: items are rebuilt per row from what that row currently allows. */
@ViewChild('rowMenu') rowMenu!: ContextMenuComponent;
rowMenuItems: { text: string }[] = [];
private contextRow: 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;
private toastTimer?: ReturnType<typeof setTimeout>; private toastTimer?: ReturnType<typeof setTimeout>;
@@ -79,6 +85,52 @@ export class ExpensesPageComponent implements OnInit {
this.load(); this.load();
} }
// ── Mobile pager (the Kendo grid pager is desktop-only) ───────────────────────
get totalPages(): number { return Math.max(1, Math.ceil(this.total / this.pageSize)); }
prevPage(): void {
if (this.page <= 1) return;
this.page--;
this.load();
}
nextPage(): void {
if (this.page >= this.totalPages) return;
this.page++;
this.load();
}
// ── Row interaction: right-click opens the per-row action menu ────────────────
onCellClick(event: CellClickEvent): void {
if (event.type !== 'contextmenu') return;
event.originalEvent.preventDefault();
const items = this.buildMenuItems(event.dataItem);
if (items.length === 0) return;
this.contextRow = event.dataItem;
this.rowMenuItems = items;
this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
}
onRowMenuSelect(event: ContextMenuSelectEvent): void {
const row = this.contextRow;
if (!row) return;
switch (event.item.text) {
case 'Edit': this.openEdit(row); break;
case 'Review': this.openReview(row); break;
case 'Pay': this.openPay(row); break;
case 'Receipt': this.openReceipt(row.id); break;
}
}
private buildMenuItems(row: ExpenseListItemDto): { text: string }[] {
const items: { text: string }[] = [];
if (this.canEdit(row)) items.push({ text: 'Edit' });
if (this.canApproveOrReject(row)) items.push({ text: 'Review' });
if (this.canPay(row)) items.push({ text: 'Pay' });
if (row.hasReceipt) items.push({ text: 'Receipt' });
return items;
}
onVendorSave(result: ExpenseFormResult): void { onVendorSave(result: ExpenseFormResult): void {
this.api.create(result.request).subscribe(() => { this.vendorDialogOpen = false; this.load(); }); this.api.create(result.request).subscribe(() => { this.vendorDialogOpen = false; this.load(); });
} }