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; private readonly destroy$ = new Subject(); 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 { 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}`; } }