441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
import { Component, OnDestroy, OnInit } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { Subject, takeUntil, firstValueFrom } from 'rxjs';
|
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
|
import { buildProofPdf } from '../../services/proof-pdf.builder';
|
|
import { OfferingEntryApiService } from '../../services/offering-entry-api.service';
|
|
import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service';
|
|
import {
|
|
GivingCategoryDto, OfferingGivingLineRequest, MemberTypeaheadDto, QuickAddMemberRequest,
|
|
OfferingGivingLineDto, PaymentMethod,
|
|
} from '../../models/giving.model';
|
|
import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';
|
|
|
|
interface MemberOption { id: number; displayName: string; }
|
|
|
|
/** One row of the totals dialog's payment-method breakdown. */
|
|
interface MethodSubtotal { method: PaymentMethod; label: string; total: number; }
|
|
|
|
/**
|
|
* Portrait, phone-friendly page where a volunteer records one Sunday offering
|
|
* at a time. Fields mirror the desktop "Add Giving" form. Each submit persists
|
|
* a single line to the current week's Sunday session (find-or-create, server-
|
|
* side) and the form resets blank for the next entry. No login required.
|
|
*/
|
|
@Component({
|
|
selector: 'app-offering-entry-mobile-page',
|
|
standalone: true,
|
|
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DropDownsModule, DialogsModule],
|
|
templateUrl: './offering-entry-mobile-page.component.html',
|
|
styleUrls: ['./offering-entry-mobile-page.component.scss'],
|
|
})
|
|
export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
|
// The Sunday whose offering this session records. The week runs Sunday→Saturday,
|
|
// so a Sunday's gifts can still be keyed any day through the following Saturday:
|
|
// entered on Sat 6/20 → 6/14; on Sun 6/21 (and through Sat 6/27) → 6/21.
|
|
readonly sessionDate = this.sundayOf(new Date());
|
|
private readonly session = this.toIso(this.sessionDate);
|
|
|
|
categories: GivingCategoryDto[] = [];
|
|
readonly paymentMethods = PAYMENT_METHOD_OPTIONS;
|
|
|
|
memberResults: MemberOption[] = [];
|
|
selectedMemberId: number | null = null;
|
|
selectedMemberName: string | null = null;
|
|
|
|
entry: OfferingGivingLineRequest = this.blankEntry();
|
|
|
|
// Live running tally for today — seeded by bootstrap, kept current by SignalR
|
|
// (so multiple phones agree) and by each successful submit.
|
|
lineCount = 0;
|
|
systemTotal = 0;
|
|
|
|
submitting = false;
|
|
toast: string | null = null;
|
|
connected = false;
|
|
|
|
// Quick-add dialog for a giver who isn't on file yet.
|
|
showQuickAdd = false;
|
|
quickAddSaving = false;
|
|
quickAdd: QuickAddMemberRequest = this.blankQuickAdd();
|
|
|
|
// Paper-proof dialog: photos/PDFs of the count sheet / envelopes for today's
|
|
// session. Staged here, compressed + merged into one PDF on attach.
|
|
showPaperProof = false;
|
|
paperProofSaving = false;
|
|
paperProofFiles: File[] = [];
|
|
hasProof = false; // whether today's session already has a proof PDF
|
|
|
|
// Totals dialog: opened from the "今日總額" tally. Lines are refetched on open so
|
|
// the breakdown is a fresh cross-phone snapshot, not the (possibly stale) lines
|
|
// loaded at bootstrap.
|
|
showTotals = false;
|
|
totalsLoading = false;
|
|
private totalsLines: OfferingGivingLineDto[] = [];
|
|
|
|
private toastTimer?: ReturnType<typeof setTimeout>;
|
|
private readonly destroy$ = new Subject<void>();
|
|
|
|
constructor(
|
|
private api: OfferingEntryApiService,
|
|
private signalr: OfferingEntrySignalrService,
|
|
) {}
|
|
|
|
ngOnInit(): void {
|
|
this.api.bootstrap(this.session).subscribe(dto => {
|
|
this.categories = dto.categories;
|
|
this.entry.givingCategoryId = dto.categories[0]?.id ?? 0;
|
|
this.lineCount = dto.summary.lineCount;
|
|
this.systemTotal = dto.summary.systemTotal;
|
|
this.hasProof = dto.summary.hasProof;
|
|
});
|
|
|
|
this.signalr.lineAdded$
|
|
.pipe(takeUntil(this.destroy$))
|
|
.subscribe(evt => {
|
|
if (evt.sessionDate !== this.session) {
|
|
return;
|
|
}
|
|
this.lineCount = evt.lineCount;
|
|
this.systemTotal = evt.systemTotal;
|
|
});
|
|
|
|
this.signalr.start()
|
|
.then(() => {
|
|
this.connected = true;
|
|
return this.signalr.joinDate(this.session);
|
|
})
|
|
.catch(() => (this.connected = false));
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
if (this.toastTimer) {
|
|
clearTimeout(this.toastTimer);
|
|
}
|
|
this.signalr.leaveDate(this.session)
|
|
.catch(() => undefined)
|
|
.then(() => this.signalr.stop());
|
|
}
|
|
|
|
get canSubmit(): boolean {
|
|
if (this.submitting || this.entry.amount <= 0) {
|
|
return false;
|
|
}
|
|
// A named gift must have a giver — only an explicitly anonymous gift may omit one.
|
|
if (!this.entry.isAnonymous && this.entry.memberId == null) {
|
|
return false;
|
|
}
|
|
if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
onMemberFilter(term: string): void {
|
|
if (!term) {
|
|
this.memberResults = [];
|
|
return;
|
|
}
|
|
this.api.searchMembers(term, 10).subscribe(list =>
|
|
this.memberResults = list.map(m => ({ id: m.id, displayName: this.giverLabel(m) })));
|
|
}
|
|
|
|
// "NickName LastName (Legal FirstName LastName)" — the legal name in parens so
|
|
// a nick name is never ambiguous. Falls back to the legal name alone when there
|
|
// is no nick name (or it's the same as the legal first name).
|
|
private giverLabel(m: MemberTypeaheadDto): string {
|
|
const legal = `${m.firstName_en} ${m.lastName_en}`.trim();
|
|
const base = (m.nickName && m.nickName !== m.firstName_en)
|
|
? `${m.nickName} ${m.lastName_en} (${legal})`
|
|
: legal;
|
|
// Append the company / business name so a company-check giver is unambiguous.
|
|
return m.entity ? `${base} · ${m.entity}` : base;
|
|
}
|
|
|
|
onMemberSelected(id: number | null): void {
|
|
this.selectedMemberId = id ?? null;
|
|
this.entry.memberId = this.selectedMemberId;
|
|
this.selectedMemberName = this.memberResults.find(m => m.id === id)?.displayName ?? null;
|
|
if (id != null) {
|
|
this.entry.isAnonymous = false;
|
|
}
|
|
}
|
|
|
|
markAnonymous(): void {
|
|
this.entry.isAnonymous = true;
|
|
this.entry.memberId = null;
|
|
this.selectedMemberId = null;
|
|
this.selectedMemberName = null;
|
|
this.memberResults = [];
|
|
}
|
|
|
|
clearAnonymous(): void {
|
|
this.entry.isAnonymous = false;
|
|
}
|
|
|
|
// ── Quick-add member ────────────────────────────────────────────────────────
|
|
|
|
openQuickAdd(): void {
|
|
this.quickAdd = this.blankQuickAdd();
|
|
this.showQuickAdd = true;
|
|
}
|
|
|
|
cancelQuickAdd(): void {
|
|
this.showQuickAdd = false;
|
|
}
|
|
|
|
get canSaveQuickAdd(): boolean {
|
|
return !this.quickAddSaving
|
|
&& !!this.quickAdd.firstName_en?.trim()
|
|
&& !!this.quickAdd.lastName_en?.trim();
|
|
}
|
|
|
|
saveQuickAdd(): void {
|
|
if (!this.canSaveQuickAdd) {
|
|
return;
|
|
}
|
|
this.quickAddSaving = true;
|
|
const request: QuickAddMemberRequest = {
|
|
firstName_en: this.quickAdd.firstName_en.trim(),
|
|
lastName_en: this.quickAdd.lastName_en.trim(),
|
|
nickName: this.trimToNull(this.quickAdd.nickName),
|
|
firstName_zh: this.trimToNull(this.quickAdd.firstName_zh),
|
|
lastName_zh: this.trimToNull(this.quickAdd.lastName_zh),
|
|
entity: this.trimToNull(this.quickAdd.entity),
|
|
phoneCell: this.trimToNull(this.quickAdd.phoneCell),
|
|
};
|
|
this.api.quickAddMember(request).subscribe({
|
|
next: created => {
|
|
this.quickAddSaving = false;
|
|
this.showQuickAdd = false;
|
|
// Seed the new member into the typeahead and select them immediately.
|
|
const option = { id: created.id, displayName: this.giverLabel(created) };
|
|
this.memberResults = [option, ...this.memberResults.filter(m => m.id !== created.id)];
|
|
this.entry.isAnonymous = false;
|
|
this.onMemberSelected(created.id);
|
|
this.showToast('已新增會友 ✓ Member added');
|
|
},
|
|
error: (err: { error?: { message?: string } }) => {
|
|
this.quickAddSaving = false;
|
|
this.showToast(err?.error?.message ?? '新增失敗 Add failed');
|
|
},
|
|
});
|
|
}
|
|
|
|
private blankQuickAdd(): QuickAddMemberRequest {
|
|
return {
|
|
firstName_en: '', lastName_en: '', nickName: null,
|
|
firstName_zh: null, lastName_zh: null, entity: null, phoneCell: null,
|
|
};
|
|
}
|
|
|
|
private trimToNull(value: string | null): string | null {
|
|
const trimmed = value?.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
|
|
// ── Paper proof ─────────────────────────────────────────────────────────────
|
|
|
|
openPaperProof(): void {
|
|
this.paperProofFiles = [];
|
|
this.showPaperProof = true;
|
|
}
|
|
|
|
cancelPaperProof(): void {
|
|
this.showPaperProof = false;
|
|
}
|
|
|
|
// Shared by the camera and library file inputs — accumulate picks and clear the
|
|
// input so the same file can be re-selected if it was removed.
|
|
onProofFilesSelected(event: Event): void {
|
|
const input = event.target as HTMLInputElement;
|
|
const picked = Array.from(input.files ?? []);
|
|
if (picked.length) {
|
|
this.paperProofFiles = [...this.paperProofFiles, ...picked];
|
|
}
|
|
input.value = '';
|
|
}
|
|
|
|
removeProofFile(index: number): void {
|
|
this.paperProofFiles = this.paperProofFiles.filter((_, i) => i !== index);
|
|
}
|
|
|
|
get canSavePaperProof(): boolean {
|
|
return !this.paperProofSaving && this.paperProofFiles.length > 0;
|
|
}
|
|
|
|
async savePaperProof(): Promise<void> {
|
|
if (!this.canSavePaperProof) {
|
|
return;
|
|
}
|
|
this.paperProofSaving = true;
|
|
try {
|
|
const files = [...this.paperProofFiles];
|
|
if (this.hasProof) {
|
|
// Merge into today's existing proof: prepend its pages so the order stays
|
|
// chronological. A 204 (no proof) comes back as an empty Blob — skip it.
|
|
const existing = await firstValueFrom(this.api.downloadProof(this.session));
|
|
if (existing.size > 0) {
|
|
files.unshift(new File([existing], 'existing-proof.pdf', { type: 'application/pdf' }));
|
|
}
|
|
}
|
|
const { blob, skipped } = await buildProofPdf(files);
|
|
await firstValueFrom(this.api.uploadProof(this.session, blob));
|
|
this.hasProof = true;
|
|
this.paperProofSaving = false;
|
|
this.showPaperProof = false;
|
|
this.paperProofFiles = [];
|
|
this.showToast(skipped.length
|
|
? `已附證明,略過 ${skipped.length} 個不支援檔案 · Attached (${skipped.length} skipped)`
|
|
: '已附紙本證明 ✓ Proof attached');
|
|
} catch (err: unknown) {
|
|
this.paperProofSaving = false;
|
|
const message = (err as { error?: { message?: string } })?.error?.message;
|
|
this.showToast(message ?? '附加失敗 Attach failed');
|
|
}
|
|
}
|
|
|
|
// ── Totals dialog ───────────────────────────────────────────────────────────
|
|
|
|
openTotals(): void {
|
|
this.showTotals = true;
|
|
this.totalsLoading = true;
|
|
this.totalsLines = [];
|
|
// Refetch so the breakdown reflects every phone's entries at this moment.
|
|
this.api.bootstrap(this.session).subscribe({
|
|
next: dto => {
|
|
this.totalsLines = dto.summary.lines;
|
|
this.totalsLoading = false;
|
|
},
|
|
error: () => {
|
|
this.showTotals = false;
|
|
this.totalsLoading = false;
|
|
this.showToast('讀取總計失敗 Failed to load totals');
|
|
},
|
|
});
|
|
}
|
|
|
|
closeTotals(): void {
|
|
this.showTotals = false;
|
|
}
|
|
|
|
// One row per payment method that has at least one line, in the canonical
|
|
// PAYMENT_METHOD_OPTIONS order (Cash → Check → Zelle → PayPal → Other).
|
|
get methodSubtotals(): MethodSubtotal[] {
|
|
return PAYMENT_METHOD_OPTIONS
|
|
.map(option => {
|
|
const method = option.value as PaymentMethod;
|
|
const total = this.totalsLines
|
|
.filter(line => line.paymentMethod === method)
|
|
.reduce((sum, line) => sum + line.amount, 0);
|
|
return { method, label: option.label, total };
|
|
})
|
|
.filter(row => row.total > 0);
|
|
}
|
|
|
|
get checkLines(): OfferingGivingLineDto[] {
|
|
return this.totalsLines.filter(line => line.paymentMethod === 'Check');
|
|
}
|
|
|
|
get checkTotal(): number {
|
|
return this.checkLines.reduce((sum, line) => sum + line.amount, 0);
|
|
}
|
|
|
|
get grandTotal(): number {
|
|
return this.totalsLines.reduce((sum, line) => sum + line.amount, 0);
|
|
}
|
|
|
|
submit(): void {
|
|
if (!this.canSubmit) {
|
|
return;
|
|
}
|
|
this.submitting = true;
|
|
this.api.appendLine(this.session, this.normalizedLine()).subscribe({
|
|
next: res => {
|
|
this.submitting = false;
|
|
// Server is the source of truth; update now in case our own broadcast
|
|
// hasn't echoed back to this client yet.
|
|
this.lineCount = res.lineCount;
|
|
this.systemTotal = res.systemTotal;
|
|
this.showToast('已登打 ✓ Recorded');
|
|
this.resetForm();
|
|
},
|
|
error: (err: { error?: { message?: string } }) => {
|
|
this.submitting = false;
|
|
this.showToast(err?.error?.message ?? '登打失敗 Submit failed');
|
|
},
|
|
});
|
|
}
|
|
|
|
// Send null for fields that don't apply to the chosen method so a stale check
|
|
// number (etc.) from a since-changed method isn't persisted.
|
|
private normalizedLine(): OfferingGivingLineRequest {
|
|
const e = this.entry;
|
|
return {
|
|
memberId: e.isAnonymous ? null : e.memberId,
|
|
givingCategoryId: e.givingCategoryId,
|
|
amount: e.amount,
|
|
paymentMethod: e.paymentMethod,
|
|
checkNumber: e.paymentMethod === 'Check' ? (e.checkNumber || null) : null,
|
|
zelleReferenceCode: e.paymentMethod === 'Zelle' ? (e.zelleReferenceCode || null) : null,
|
|
payPalTransactionId: e.paymentMethod === 'PayPal' ? (e.payPalTransactionId || null) : null,
|
|
isAnonymous: e.isAnonymous,
|
|
notes: e.notes || null,
|
|
};
|
|
}
|
|
|
|
private resetForm(): void {
|
|
const defaultCategory = this.categories[0]?.id ?? 0;
|
|
// Keep the last-used payment method — a counter usually enters several gifts
|
|
// of the same method in a row, so they don't have to re-pick it each time.
|
|
// The per-gift reference fields (check #, Zelle, PayPal) still clear below.
|
|
const keepMethod = this.entry.paymentMethod;
|
|
this.entry = this.blankEntry();
|
|
this.entry.givingCategoryId = defaultCategory;
|
|
this.entry.paymentMethod = keepMethod;
|
|
this.selectedMemberId = null;
|
|
this.selectedMemberName = null;
|
|
this.memberResults = [];
|
|
}
|
|
|
|
private blankEntry(): OfferingGivingLineRequest {
|
|
return {
|
|
memberId: null, givingCategoryId: 0, amount: 0, paymentMethod: 'Cash',
|
|
checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null,
|
|
isAnonymous: false, notes: null,
|
|
};
|
|
}
|
|
|
|
private showToast(message: string): void {
|
|
this.toast = message;
|
|
if (this.toastTimer) {
|
|
clearTimeout(this.toastTimer);
|
|
}
|
|
this.toastTimer = setTimeout(() => (this.toast = null), 2200);
|
|
}
|
|
|
|
// The most recent Sunday on or before the given date (Sun→Sat week). getDay()
|
|
// returns 0 for Sunday, so subtracting it lands on this week's Sunday — and on
|
|
// a Sunday it subtracts 0, keeping that same day.
|
|
private sundayOf(d: Date): Date {
|
|
const sunday = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
sunday.setDate(sunday.getDate() - sunday.getDay());
|
|
return sunday;
|
|
}
|
|
|
|
// Format using LOCAL date components — NOT toISOString(), which converts to UTC
|
|
// and can roll the date forward a day for behind-UTC users.
|
|
private toIso(d: Date): string {
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
}
|