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(); }
}