Files
ROLAC/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts
T
2026-06-24 11:35:34 -07:00

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}`;
}
}