This commit is contained in:
Chris Chen
2026-06-20 17:51:33 -07:00
parent f55807fa7d
commit 3558c67fd7
55 changed files with 3140 additions and 85 deletions
@@ -0,0 +1,75 @@
<kendo-dialog title="Issue Checks / 開立支票" [width]="720" (close)="onClose()">
<div class="p-2 flex flex-col gap-4" style="max-height: 70vh; overflow-y: auto;">
<label class="flex flex-col gap-1 w-60">
Check Date / 支票日期
<kendo-datepicker [(ngModel)]="checkDate"></kendo-datepicker>
</label>
<div *ngFor="let f of forms; let i = index" class="border rounded p-3 flex flex-col gap-3"
style="border-color:#e5e7eb;">
<div class="font-semibold">
Check #{{ i + 1 }} — {{ f.group.payeeType }} · Total {{ f.group.totalAmount | currency }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2">
Payee Name / 收款人
<kendo-textbox [(ngModel)]="f.payeeName"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Address / 地址
<kendo-textbox [(ngModel)]="f.address"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
City / 城市
<kendo-textbox [(ngModel)]="f.city"></kendo-textbox>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col gap-1">
State / 州
<kendo-textbox [(ngModel)]="f.state"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Zip / 郵遞區號
<kendo-textbox [(ngModel)]="f.zip"></kendo-textbox>
</label>
</div>
<label class="flex flex-col gap-1">
Check # / 支票號碼
<kendo-textbox [(ngModel)]="f.checkNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Memo / 摘要
<kendo-textbox [(ngModel)]="f.memo"></kendo-textbox>
</label>
</div>
<table class="w-full text-sm">
<thead>
<tr class="text-left" style="border-bottom:1px solid #e5e7eb;">
<th class="py-1">Date</th><th>Description</th><th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let l of f.group.lines" style="border-bottom:1px solid #f3f4f6;">
<td class="py-1">{{ l.expenseDate }}</td>
<td>{{ l.description }}</td>
<td class="text-right">{{ l.amount | currency }}</td>
</tr>
<tr class="font-semibold">
<td></td><td class="text-right pr-2">Total</td>
<td class="text-right">{{ f.group.totalAmount | currency }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="onClose()">Cancel</button>
<button kendoButton themeColor="primary" (click)="confirm()">
Issue {{ forms.length }} Check(s)
</button>
</kendo-dialog-actions>
</kendo-dialog>
@@ -0,0 +1,83 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { GridModule } from '@progress/kendo-angular-grid';
import { PayeeGroupDto, IssueChecksRequest, PayeeCheckInstruction } from '../../models/disbursement.model';
interface PayeeForm {
group: PayeeGroupDto;
payeeName: string;
address: string;
city: string;
state: string;
zip: string;
checkNumber: string;
defaultCheckNumber: string;
memo: string;
}
@Component({
selector: 'app-issue-check-dialog',
standalone: true,
imports: [CommonModule, FormsModule, DialogsModule, ButtonsModule, InputsModule, DateInputsModule, GridModule],
templateUrl: './issue-check-dialog.component.html',
})
export class IssueCheckDialogComponent implements OnInit {
/** The payee groups the user selected to pay. */
@Input() groups: PayeeGroupDto[] = [];
/** Next sequential check number from the church profile, used to prefill. */
@Input() nextCheckNumber = 1001;
@Output() save = new EventEmitter<IssueChecksRequest>();
@Output() cancel = new EventEmitter<void>();
checkDate = new Date();
forms: PayeeForm[] = [];
ngOnInit(): void {
let n = this.nextCheckNumber;
this.forms = this.groups.map(g => {
const cn = String(n++);
return {
group: g,
payeeName: g.payeeName,
address: g.address ?? '',
city: g.city ?? '',
state: g.state ?? '',
zip: g.zip ?? '',
checkNumber: cn,
defaultCheckNumber: cn,
memo: '',
};
});
}
confirm(): void {
const d = this.checkDate;
const checkDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payees: PayeeCheckInstruction[] = this.forms.map(f => ({
payeeType: f.group.payeeType,
memberId: f.group.memberId,
vendorKey: f.group.vendorKey,
payeeName: f.payeeName.trim(),
address: f.address.trim() || null,
city: f.city.trim() || null,
state: f.state.trim() || null,
zip: f.zip.trim() || null,
// Only send an override when the user changed the prefilled sequential number,
// so the server's auto-allocation (and counter consumption) stays in sync.
checkNumberOverride: f.checkNumber.trim() !== f.defaultCheckNumber ? f.checkNumber.trim() : null,
memo: f.memo.trim() || null,
expenseIds: f.group.lines.map(l => l.expenseId),
}));
this.save.emit({ checkDate, payees });
}
onClose(): void { this.cancel.emit(); }
}
@@ -0,0 +1,31 @@
<kendo-dialog title="Receipt Acknowledgement / 簽收" [width]="480" (close)="onClose()">
<div class="p-2 flex flex-col gap-3">
<div class="text-sm" style="color:#374151;">
Check #{{ check.checkNumber }} · {{ check.payeeName }} · {{ check.amount | currency }}
</div>
<label class="flex flex-col gap-1">
Signed Name / 簽收人姓名
<kendo-textbox [(ngModel)]="signedName" placeholder="Recipient name"></kendo-textbox>
</label>
<div class="flex flex-col gap-1">
<span class="text-sm">Signature / 簽名</span>
<canvas #pad width="440" height="180"
class="border rounded touch-none"
style="border-color:#9ca3af; background:#fff; touch-action:none;"
(pointerdown)="onDown($event)"
(pointermove)="onMove($event)"
(pointerup)="onUp()"
(pointerleave)="onUp()">
</canvas>
<button kendoButton fillMode="flat" class="self-start" (click)="clear()">Clear / 清除</button>
</div>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="onClose()">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!hasInk || !signedName.trim() || submitting"
(click)="confirm()">Confirm / 確認簽收</button>
</kendo-dialog-actions>
</kendo-dialog>
@@ -0,0 +1,87 @@
import { AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { CheckListItemDto } from '../../models/disbursement.model';
export interface ReceiptSignResult { signature: Blob; signedName: string; }
@Component({
selector: 'app-receipt-sign-dialog',
standalone: true,
imports: [CommonModule, FormsModule, DialogsModule, ButtonsModule, InputsModule],
templateUrl: './receipt-sign-dialog.component.html',
})
export class ReceiptSignDialogComponent implements AfterViewInit {
@Input() check!: CheckListItemDto;
@Output() save = new EventEmitter<ReceiptSignResult>();
@Output() cancel = new EventEmitter<void>();
@ViewChild('pad', { static: false }) padRef!: ElementRef<HTMLCanvasElement>;
signedName = '';
hasInk = false;
submitting = false;
private ctx!: CanvasRenderingContext2D;
private drawing = false;
ngAfterViewInit(): void {
const canvas = this.padRef.nativeElement;
this.signedName = this.check.payeeName || '';
this.ctx = canvas.getContext('2d')!;
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, canvas.width, canvas.height);
this.ctx.strokeStyle = '#111827';
this.ctx.lineWidth = 2;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
}
private point(e: PointerEvent): { x: number; y: number } {
const rect = this.padRef.nativeElement.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (this.padRef.nativeElement.width / rect.width),
y: (e.clientY - rect.top) * (this.padRef.nativeElement.height / rect.height),
};
}
onDown(e: PointerEvent): void {
e.preventDefault();
this.drawing = true;
const p = this.point(e);
this.ctx.beginPath();
this.ctx.moveTo(p.x, p.y);
this.padRef.nativeElement.setPointerCapture(e.pointerId);
}
onMove(e: PointerEvent): void {
if (!this.drawing) return;
const p = this.point(e);
this.ctx.lineTo(p.x, p.y);
this.ctx.stroke();
this.hasInk = true;
}
onUp(): void { this.drawing = false; }
clear(): void {
const canvas = this.padRef.nativeElement;
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, canvas.width, canvas.height);
this.hasInk = false;
}
confirm(): void {
if (!this.hasInk || !this.signedName.trim() || this.submitting) return;
this.submitting = true;
this.padRef.nativeElement.toBlob(blob => {
if (!blob) { this.submitting = false; return; }
this.save.emit({ signature: blob, signedName: this.signedName.trim() });
}, 'image/png');
}
onClose(): void { this.cancel.emit(); }
}
@@ -0,0 +1,53 @@
export type PayeeType = 'Vendor' | 'Member';
export type CheckStatus = 'Issued' | 'Voided';
export interface PagedResult<T> {
items: T[]; totalCount: number; page: number; pageSize: number; totalPages: number;
}
export interface ExpenseLineDto {
expenseId: number; expenseDate: string; description: string; amount: number;
ministryName: string; categoryName: string;
}
export interface PayeeGroupDto {
payeeType: PayeeType; memberId: number | null; vendorKey: string | null;
payeeName: string; address: string | null; city: string | null;
state: string | null; zip: string | null;
totalAmount: number; lines: ExpenseLineDto[];
}
export interface PayeeCheckInstruction {
payeeType: PayeeType; memberId: number | null; vendorKey: string | null;
payeeName: string; address: string | null; city: string | null;
state: string | null; zip: string | null;
checkNumberOverride: string | null; memo: string | null; expenseIds: number[];
}
export interface IssueChecksRequest { checkDate: string; payees: PayeeCheckInstruction[]; }
export interface IssuedCheckDto { checkId: number; checkNumber: string; payeeName: string; amount: number; }
export interface IssueChecksResultDto { created: IssuedCheckDto[]; }
export interface CheckListItemDto {
id: number; checkNumber: string; checkDate: string; amount: number;
payeeType: PayeeType; payeeName: string; status: CheckStatus; lineCount: number;
signed: boolean; receiptSignedName: string | null; receiptSignedAt: string | null;
}
export interface CheckLineDto { expenseId: number; description: string; amount: number; }
export interface CheckDetailDto extends CheckListItemDto {
memberId: number | null; address: string | null; city: string | null;
state: string | null; zip: string | null; memo: string | null;
voidReason: string | null; voidedAt: string | null; issuedAt: string;
lines: CheckLineDto[];
}
export interface ChurchProfileDto {
id: number; name: string; address: string | null; city: string | null;
state: string | null; zipCode: string | null; bankName: string | null;
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
}
export type UpdateChurchProfileRequest = Omit<ChurchProfileDto, 'id'>;
@@ -0,0 +1,120 @@
<div class="page">
<header class="page-header">
<h2>Check Register / 支票登記簿</h2>
</header>
<div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1">
Search
<kendo-textbox placeholder="Check # / payee" [(ngModel)]="filter.search"
(keydown.enter)="applyFilter()"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Status
<kendo-dropdownlist [data]="statuses" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="filter.status" [defaultItem]="{ value: null, label: 'All Status/全部狀態' }">
</kendo-dropdownlist>
</label>
<button kendoButton (click)="applyFilter()">Apply</button>
</div>
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
<kendo-grid-column field="checkNumber" title="Check #" [width]="100"></kendo-grid-column>
<kendo-grid-column field="checkDate" title="Date" [width]="110"></kendo-grid-column>
<kendo-grid-column field="payeeName" title="Payee"></kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="120" format="c2"></kendo-grid-column>
<kendo-grid-column title="Lines" [width]="80">
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lineCount }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Status" [width]="110">
<ng-template kendoGridCellTemplate let-dataItem>
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Receipt / 簽收" [width]="180">
<ng-template kendoGridCellTemplate let-dataItem>
<ng-container *ngIf="dataItem.signed; else notSigned">
<span class="badge-paid">Signed</span>
<div class="text-xs" style="color:#6b7280;">
{{ dataItem.receiptSignedName }} · {{ dataItem.receiptSignedAt | date:'short' }}
</div>
</ng-container>
<ng-template #notSigned><span style="color:#9ca3af;"></span></ng-template>
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="600">
<ng-template kendoGridCellTemplate let-dataItem>
<button kendoButton fillMode="flat" (click)="view(dataItem)">View</button>
<button kendoButton fillMode="flat" themeColor="primary" (click)="print(dataItem)">Print</button>
<button *ngIf="canSign(dataItem)" kendoButton fillMode="flat" themeColor="success"
(click)="openSign(dataItem)">簽收</button>
<button *ngIf="dataItem.signed" kendoButton fillMode="flat" (click)="viewSignature(dataItem)">Signature</button>
<button *ngIf="dataItem.signed" kendoButton fillMode="flat" themeColor="primary"
(click)="printReceipt(dataItem)">收據</button>
<button *ngIf="canVoid(dataItem)" kendoButton fillMode="flat" themeColor="error"
(click)="openVoid(dataItem)">Void</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<!-- Detail dialog -->
<kendo-dialog *ngIf="detail" title="Check #{{ detail.checkNumber }}" [width]="560" (close)="detail = null">
<div class="p-2 flex flex-col gap-2">
<div class="grid grid-cols-2 gap-2 text-sm">
<div><strong>Payee:</strong> {{ detail.payeeName }}</div>
<div><strong>Date:</strong> {{ detail.checkDate }}</div>
<div><strong>Amount:</strong> {{ detail.amount | currency }}</div>
<div><strong>Status:</strong> {{ detail.status }}</div>
<div class="col-span-2" *ngIf="detail.memo"><strong>Memo:</strong> {{ detail.memo }}</div>
<div class="col-span-2" *ngIf="detail.signed">
<strong>Signed:</strong> {{ detail.receiptSignedName }} · {{ detail.receiptSignedAt | date:'short' }}
</div>
<div class="col-span-2" *ngIf="detail.status === 'Voided'">
<strong>Voided:</strong> {{ detail.voidReason }} · {{ detail.voidedAt | date:'short' }}
</div>
</div>
<table class="w-full text-sm mt-2">
<thead>
<tr class="text-left" style="border-bottom:1px solid #e5e7eb;">
<th class="py-1">Description</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let l of detail.lines" style="border-bottom:1px solid #f3f4f6;">
<td class="py-1">{{ l.description }}</td>
<td class="text-right">{{ l.amount | currency }}</td>
</tr>
</tbody>
</table>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="detail = null">Close</button>
</kendo-dialog-actions>
</kendo-dialog>
<!-- Void dialog -->
<kendo-dialog *ngIf="voidRow" title="Void Check #{{ voidRow.checkNumber }}" [width]="420" (close)="voidRow = null">
<div class="p-2 flex flex-col gap-2">
<p class="text-sm" style="color:#991b1b;">
Voiding returns the bundled expenses to Approved so they can be re-issued.
/ 作廢將使支出退回「已核准」可重新開立。
</p>
<label class="flex flex-col gap-1">
Reason / 原因
<kendo-textbox [(ngModel)]="voidReason" placeholder="Optional"></kendo-textbox>
</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="voidRow = null">Cancel</button>
<button kendoButton themeColor="error" (click)="confirmVoid()">Void</button>
</kendo-dialog-actions>
</kendo-dialog>
<!-- Receipt signature dialog -->
<app-receipt-sign-dialog *ngIf="signRow" [check]="signRow" (save)="onSign($event)" (cancel)="signRow = null">
</app-receipt-sign-dialog>
</div>
@@ -0,0 +1,26 @@
%badge-base {
display: inline-block;
padding: 2px 10px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.badge-approved {
@extend %badge-base;
background-color: #dbeafe;
color: #1e40af;
}
.badge-paid {
@extend %badge-base;
background-color: #d1fae5;
color: #065f46;
}
.badge-rejected {
@extend %badge-base;
background-color: #fee2e2;
color: #991b1b;
}
@@ -0,0 +1,107 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { CHECK_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
import { DisbursementApiService, CheckRegisterQuery } from '../../services/disbursement-api.service';
import { ReceiptSignDialogComponent, ReceiptSignResult } from '../../components/receipt-sign-dialog/receipt-sign-dialog.component';
import { CheckListItemDto, CheckDetailDto } from '../../models/disbursement.model';
@Component({
selector: 'app-check-register-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
InputsModule, DialogsModule, ReceiptSignDialogComponent,
],
templateUrl: './check-register-page.component.html',
styleUrls: ['./check-register-page.component.scss'],
})
export class CheckRegisterPageComponent implements OnInit {
rows: CheckListItemDto[] = [];
total = 0;
page = 1;
pageSize = 20;
loading = false;
readonly statuses = CHECK_STATUS_OPTIONS;
filter: CheckRegisterQuery = {};
detail: CheckDetailDto | null = null;
signRow: CheckListItemDto | null = null;
voidRow: CheckListItemDto | null = null;
voidReason = '';
constructor(private api: DisbursementApiService) {}
ngOnInit(): void { this.load(); }
load(): void {
this.loading = true;
this.api.getRegister({ ...this.filter, page: this.page, pageSize: this.pageSize }).subscribe({
next: r => { this.rows = r.items; this.total = r.totalCount; this.loading = false; },
error: () => (this.loading = false),
});
}
get skip(): number { return (this.page - 1) * this.pageSize; }
applyFilter(): void { this.page = 1; this.load(); }
onPageChange(e: PageChangeEvent): void { this.page = Math.floor(e.skip / this.pageSize) + 1; this.load(); }
view(row: CheckListItemDto): void {
this.api.getCheck(row.id).subscribe(d => (this.detail = d));
}
print(row: CheckListItemDto): void {
this.api.downloadCheckPdf(row.id).subscribe(blob => this.openBlob(blob));
}
viewSignature(row: CheckListItemDto): void {
this.api.getSignature(row.id).subscribe(blob => this.openBlob(blob));
}
printReceipt(row: CheckListItemDto): void {
this.api.downloadReceiptPdf(row.id).subscribe(blob => this.openBlob(blob));
}
openSign(row: CheckListItemDto): void { this.signRow = row; }
onSign(result: ReceiptSignResult): void {
if (!this.signRow) return;
this.api.acknowledge(this.signRow.id, result.signature, result.signedName).subscribe({
next: () => { this.signRow = null; this.load(); },
error: () => {
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
},
});
}
openVoid(row: CheckListItemDto): void { this.voidRow = row; this.voidReason = ''; }
confirmVoid(): void {
if (!this.voidRow) return;
this.api.voidCheck(this.voidRow.id, this.voidReason || null).subscribe({
next: () => { this.voidRow = null; this.load(); },
error: () => {
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
},
});
}
canSign(row: CheckListItemDto): boolean { return row.status === 'Issued' && !row.signed; }
canVoid(row: CheckListItemDto): boolean { return row.status === 'Issued'; }
statusClass(status: string): string {
return ({ Issued: 'badge-approved', Voided: 'badge-rejected' } as Record<string, string>)[status] ?? '';
}
private openBlob(blob: Blob): void {
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
}
@@ -0,0 +1,53 @@
<div class="page">
<header class="page-header">
<h2>Church Profile / 教會資料</h2>
</header>
<div *ngIf="model" class="max-w-3xl">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2">
Church Name / 教會名稱
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Address / 地址
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
City / 城市
<kendo-textbox [(ngModel)]="model.city"></kendo-textbox>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col gap-1">
State / 州
<kendo-textbox [(ngModel)]="model.state"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Zip / 郵遞區號
<kendo-textbox [(ngModel)]="model.zipCode"></kendo-textbox>
</label>
</div>
<label class="flex flex-col gap-1">
Bank Name / 銀行名稱
<kendo-textbox [(ngModel)]="model.bankName"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Bank Account # / 銀行帳號
<kendo-textbox [(ngModel)]="model.bankAccountNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Routing # / 路由號碼
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Next Check # / 下一張支票號碼
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
</label>
</div>
<div class="flex items-center gap-3 mt-4">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
</div>
@@ -0,0 +1,39 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DisbursementApiService } from '../../services/disbursement-api.service';
import { ChurchProfileDto } from '../../models/disbursement.model';
@Component({
selector: 'app-church-profile-page',
standalone: true,
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
templateUrl: './church-profile-page.component.html',
})
export class ChurchProfilePageComponent implements OnInit {
model: ChurchProfileDto | null = null;
saving = false;
savedMsg = '';
constructor(private api: DisbursementApiService) {}
ngOnInit(): void {
this.api.getChurchProfile().subscribe(p => (this.model = p));
}
save(): void {
if (!this.model || this.saving) return;
this.saving = true;
this.savedMsg = '';
const { id, ...req } = this.model;
this.api.updateChurchProfile(req).subscribe({
next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; },
error: () => {
// Error message is shown globally by httpErrorInterceptor.
this.saving = false;
},
});
}
}
@@ -0,0 +1,59 @@
<div class="page">
<header class="page-header flex items-center justify-between">
<h2>Disbursement Management / 支票開立</h2>
<button kendoButton themeColor="primary" [disabled]="selectedCount === 0" (click)="openIssue()">
Issue Checks ({{ selectedCount }})
</button>
</header>
<p class="text-sm mb-3" style="color:#6b7280;">
Approved expenses awaiting payment, grouped by payee. Select payees and issue one check each.
/ 已核准待付款支出,依收款人彙整,每位收款人開立一張支票。
</p>
<kendo-grid
[kendoGridBinding]="rows"
[loading]="loading"
kendoGridSelectBy="key"
[(selectedKeys)]="selectedKeys"
[selectable]="{ checkboxOnly: true, mode: 'multiple' }"
[sortable]="true">
<kendo-grid-checkbox-column [width]="44" [showSelectAll]="true"></kendo-grid-checkbox-column>
<kendo-grid-column field="payeeName" title="Payee / 收款人"></kendo-grid-column>
<kendo-grid-column field="payeeType" title="Type" [width]="120"></kendo-grid-column>
<kendo-grid-column title="# Expenses" [width]="120">
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lines.length }}</ng-template>
</kendo-grid-column>
<kendo-grid-column field="totalAmount" title="Total" [width]="140" format="c2"></kendo-grid-column>
<ng-template kendoGridDetailTemplate let-dataItem>
<table class="w-full text-sm">
<thead>
<tr class="text-left" style="border-bottom:1px solid #e5e7eb;">
<th class="py-1">Date</th><th>Description</th><th>Ministry</th>
<th>Category</th><th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let l of dataItem.lines" style="border-bottom:1px solid #f3f4f6;">
<td class="py-1">{{ l.expenseDate }}</td>
<td>{{ l.description }}</td>
<td>{{ l.ministryName }}</td>
<td>{{ l.categoryName }}</td>
<td class="text-right">{{ l.amount | currency }}</td>
</tr>
</tbody>
</table>
</ng-template>
</kendo-grid>
<app-issue-check-dialog
*ngIf="issueDialogGroups"
[groups]="issueDialogGroups"
[nextCheckNumber]="nextCheckNumber"
(save)="onIssue($event)"
(cancel)="issueDialogGroups = null">
</app-issue-check-dialog>
</div>
@@ -0,0 +1,3 @@
.page-header {
margin-bottom: 0.5rem;
}
@@ -0,0 +1,78 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DisbursementApiService } from '../../services/disbursement-api.service';
import { IssueCheckDialogComponent } from '../../components/issue-check-dialog/issue-check-dialog.component';
import { PayeeGroupDto, IssueChecksRequest } from '../../models/disbursement.model';
interface PayeeRow extends PayeeGroupDto { key: string; }
@Component({
selector: 'app-disbursement-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, IssueCheckDialogComponent],
templateUrl: './disbursement-page.component.html',
styleUrls: ['./disbursement-page.component.scss'],
})
export class DisbursementPageComponent implements OnInit {
rows: PayeeRow[] = [];
selectedKeys: string[] = [];
loading = false;
nextCheckNumber = 1001;
issueDialogGroups: PayeeGroupDto[] | null = null;
constructor(private api: DisbursementApiService) {}
ngOnInit(): void {
this.api.getChurchProfile().subscribe(p => (this.nextCheckNumber = p.nextCheckNumber));
this.load();
}
load(): void {
this.loading = true;
this.selectedKeys = [];
this.api.getApprovedUnpaid().subscribe({
next: groups => {
this.rows = groups.map(g => ({ ...g, key: this.keyOf(g) }));
this.loading = false;
},
error: () => (this.loading = false),
});
}
private keyOf(g: PayeeGroupDto): string {
return `${g.payeeType}:${g.payeeType === 'Member' ? g.memberId : g.vendorKey}`;
}
get selectedCount(): number { return this.selectedKeys.length; }
openIssue(): void {
const selected = this.rows.filter(r => this.selectedKeys.includes(r.key));
if (selected.length === 0) return;
this.issueDialogGroups = selected;
}
onIssue(req: IssueChecksRequest): void {
this.api.issue(req).subscribe({
next: result => {
this.issueDialogGroups = null;
result.created.forEach(c => this.openPdf(c.checkId));
this.load();
},
error: () => {
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
},
});
}
private openPdf(checkId: number): void {
this.api.downloadCheckPdf(checkId).subscribe(blob => {
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
});
}
}
@@ -0,0 +1,78 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
PagedResult, PayeeGroupDto, IssueChecksRequest, IssueChecksResultDto,
CheckListItemDto, CheckDetailDto, ChurchProfileDto, UpdateChurchProfileRequest,
} from '../models/disbursement.model';
export interface CheckRegisterQuery {
page?: number; pageSize?: number; status?: string; search?: string; from?: string; to?: string;
}
@Injectable({ providedIn: 'root' })
export class DisbursementApiService {
private readonly endpoint: string;
private readonly profileEndpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('disbursements');
this.profileEndpoint = apiConfig.getApiUrl('church-profile');
}
private toParams(q: Record<string, unknown>): HttpParams {
let p = new HttpParams();
for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== '') p = p.set(k, String(v));
return p;
}
getApprovedUnpaid(): Observable<PayeeGroupDto[]> {
return this.http.get<PayeeGroupDto[]>(`${this.endpoint}/approved-unpaid`);
}
issue(req: IssueChecksRequest): Observable<IssueChecksResultDto> {
return this.http.post<IssueChecksResultDto>(`${this.endpoint}/issue`, req);
}
getRegister(q: CheckRegisterQuery): Observable<PagedResult<CheckListItemDto>> {
return this.http.get<PagedResult<CheckListItemDto>>(`${this.endpoint}/checks`, { params: this.toParams(q as Record<string, unknown>) });
}
getCheck(id: number): Observable<CheckDetailDto> {
return this.http.get<CheckDetailDto>(`${this.endpoint}/checks/${id}`);
}
voidCheck(id: number, reason: string | null): Observable<void> {
return this.http.post<void>(`${this.endpoint}/checks/${id}/void`, { reason });
}
/** Blob fetch via HttpClient so the auth interceptor attaches the JWT (a raw window.open would 401). */
downloadCheckPdf(id: number): Observable<Blob> {
return this.http.get(`${this.endpoint}/checks/${id}/pdf`, { responseType: 'blob' });
}
/** Signed receipt PDF (check info + disbursement detail + e-signature). */
downloadReceiptPdf(id: number): Observable<Blob> {
return this.http.get(`${this.endpoint}/checks/${id}/receipt-pdf`, { responseType: 'blob' });
}
acknowledge(id: number, signature: Blob, signedName: string): Observable<void> {
const form = new FormData();
form.append('signature', signature, `check-${id}-signature.png`);
form.append('signedName', signedName);
return this.http.post<void>(`${this.endpoint}/checks/${id}/acknowledge`, form);
}
getSignature(id: number): Observable<Blob> {
return this.http.get(`${this.endpoint}/checks/${id}/signature`, { responseType: 'blob' });
}
getChurchProfile(): Observable<ChurchProfileDto> {
return this.http.get<ChurchProfileDto>(this.profileEndpoint);
}
updateChurchProfile(r: UpdateChurchProfileRequest): Observable<void> {
return this.http.put<void>(this.profileEndpoint, r);
}
}