import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Observable, Subject, from, of, map, switchMap, takeUntil } from 'rxjs'; import { buildProofPdf } from '../../services/proof-pdf.builder'; import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid'; import { InputsModule } from '@progress/kendo-angular-inputs'; import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; import { DateInputsModule } from '@progress/kendo-angular-dateinputs'; import { DialogsModule } from '@progress/kendo-angular-dialog'; import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu'; import { MealAttendanceApiService } from '../../../meal-attendance/services/meal-attendance-api.service'; import { OfferingSessionApiService } from '../../services/offering-session-api.service'; import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service'; import { GivingCategoryApiService } from '../../services/giving-category-api.service'; import { MemberApiService } from '../../../members/services/member-api.service'; import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model'; import { MemberQuickAddDialogComponent } from '../../components/member-quick-add-dialog/member-quick-add-dialog.component'; import { GivingCategoryDto, PaymentMethod, OfferingBufferLine, CreateOfferingSessionRequest, OfferingSessionListItemDto, OfferingSessionDto, OfferingGivingLineDto, } from '../../models/giving.model'; import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists'; interface MemberOption { id: number; displayName: string; } type PageMode = 'landing' | 'workspace' | 'view'; @Component({ selector: 'app-offering-session-page', standalone: true, imports: [ CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, DropDownsModule, DateInputsModule, DialogsModule, ContextMenuModule, MemberQuickAddDialogComponent, ], templateUrl: './offering-session-page.component.html', styleUrls: ['./offering-session-page.component.scss'], }) export class OfferingSessionPageComponent implements OnInit, OnDestroy { mode: PageMode = 'landing'; // The session date currently joined for live mobile-entry updates (yyyy-MM-dd), // and a teardown signal for the SignalR subscription. private liveDate: string | null = null; private readonly destroy$ = new Subject(); sessionDate: Date = new Date(); dateConflict = false; categories: GivingCategoryDto[] = []; readonly paymentMethods = PAYMENT_METHOD_OPTIONS; memberResults: MemberOption[] = []; selectedMemberId: number | null = null; selectedMemberName: string | null = null; entry = this.blankEntry(); buffer: OfferingBufferLine[] = []; editingIndex: number | null = null; cashTotal = 0; checkTotal = 0; notes: string | null = null; showQuickAdd = false; submitting = false; // Paper-proof attachments staged client-side; merged into one PDF on submit. pendingProofFiles: File[] = []; proofBusy = false; sessions: OfferingSessionListItemDto[] = []; editingSessionId: number | null = null; // Read-only session shown in `view` mode, and the confirm dialog for reopening it. viewSession: OfferingSessionDto | null = null; confirmReopenOpen = false; // Right-click actions on a Recent Sessions row. @ViewChild('sessionMenu') sessionMenu!: ContextMenuComponent; readonly sessionMenuItems = [{ text: 'View / 檢視' }, { text: '修改主日人數' }]; private contextSession: OfferingSessionListItemDto | null = null; // Edit Sunday attendance dialog. attDialogOpen = false; attSaving = false; private attDate: string | null = null; // yyyy-MM-dd of the session being edited attForm = { adult: 0, youth: 0, kid: 0 }; get attTotal(): number { return this.attForm.adult + this.attForm.youth + this.attForm.kid; } constructor( private api: OfferingSessionApiService, private categoryApi: GivingCategoryApiService, private memberApi: MemberApiService, private signalr: OfferingEntrySignalrService, private mealAttendanceApi: MealAttendanceApiService, ) { } ngOnInit(): void { this.categoryApi.getAll(false).subscribe(c => { this.categories = c; this.entry.givingCategoryId = c[0]?.id ?? 0; }); this.checkDate(); this.loadSessions(); // Live updates: when a volunteer adds a line on the mobile page for the date // we're currently viewing/editing, reflect it here without a manual refresh. this.signalr.lineAdded$ .pipe(takeUntil(this.destroy$)) .subscribe(evt => this.onMobileLineAdded(evt)); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); this.leaveLive(); this.signalr.stop(); } // ── Live mobile-entry sync ───────────────────────────────────────────────── private onMobileLineAdded(evt: { sessionDate: string; sessionId: number; line: OfferingGivingLineDto }): void { if (evt.sessionDate !== this.liveDate) { return; } if (this.mode === 'view' && this.viewSession && this.viewSession.id === evt.sessionId) { // Re-fetch so the read-only Lines list (and totals/status) stays authoritative. this.api.getById(this.viewSession.id).subscribe(dto => this.viewSession = dto); } else if (this.mode === 'workspace' && this.editingSessionId === evt.sessionId) { // Append to the open editor's buffer so it includes lines added from phones. this.buffer = [...this.buffer, this.bufferLineFromDto(evt.line)]; } } private bufferLineFromDto(g: OfferingGivingLineDto): OfferingBufferLine { return { memberId: g.memberId, givingCategoryId: g.givingCategoryId, amount: g.amount, paymentMethod: g.paymentMethod, checkNumber: g.checkNumber, zelleReferenceCode: g.zelleReferenceCode, payPalTransactionId: g.payPalTransactionId, isAnonymous: g.isAnonymous, notes: g.notes, memberName: g.memberName, categoryName: g.categoryName, }; } /** Subscribe to live updates for one session date, leaving any previous one. */ private joinLive(date: string): void { this.signalr.start() .then(() => { if (this.liveDate && this.liveDate !== date) { return this.signalr.leaveDate(this.liveDate).then(() => undefined); } return undefined; }) .then(() => { this.liveDate = date; return this.signalr.joinDate(date); }) .catch(() => { /* offline is fine — the page still works without live sync */ }); } private leaveLive(): void { if (this.liveDate) { this.signalr.leaveDate(this.liveDate).catch(() => undefined); this.liveDate = null; } } get systemTotal(): number { return this.buffer.reduce((s, l) => s + (l.amount || 0), 0); } get difference(): number { return (this.cashTotal + this.checkTotal) - this.systemTotal; } checkDate(): void { this.api.checkDate(this.toIso(this.sessionDate)).subscribe(r => this.dateConflict = r.exists); } loadSessions(): void { this.api.getPaged(1, 20).subscribe(r => this.sessions = r.items); } // Left-click anywhere on a row opens it; right-click opens the actions menu. onSessionCellClick(event: CellClickEvent): void { if (event.type === 'contextmenu') { event.originalEvent.preventDefault(); this.contextSession = event.dataItem; this.sessionMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY }); } else { this.openView(event.dataItem); } } onSessionMenuSelect(event: ContextMenuSelectEvent): void { const session = this.contextSession; if (!session) return; if (event.item.text === 'View / 檢視') this.openView(session); else if (event.item.text === '修改主日人數') this.openAttendanceEdit(session); } // Open the attendance editor, prefilling the three age groups from the existing row (zeros if none). openAttendanceEdit(session: OfferingSessionListItemDto): void { this.attDate = session.sessionDate; this.attForm = { adult: 0, youth: 0, kid: 0 }; this.attSaving = false; this.attDialogOpen = true; this.mealAttendanceApi.getRange(session.sessionDate, session.sessionDate).subscribe(rows => { const row = rows[0]; if (row) this.attForm = { adult: row.adult, youth: row.youth, kid: row.kid }; }); } saveAttendance(): void { if (!this.attDate) return; const date = this.attDate; this.attSaving = true; this.mealAttendanceApi.setCounts(date, this.attForm).subscribe({ next: counts => { const total = counts.adult + counts.youth + counts.kid; const row = this.sessions.find(s => s.sessionDate === date); if (row) row.sundayAttendanceCount = total; this.attDialogOpen = false; this.attSaving = false; }, error: (err: { error?: { message?: string } }) => { this.attSaving = false; alert(err?.error?.message ?? 'Save failed.'); }, }); } // ── Flow: landing → workspace / view ────────────────────────────────────── /** Free date chosen on the landing screen — begin a brand-new session. */ startEntry(): void { if (this.dateConflict) return; this.editingSessionId = null; this.editingIndex = null; this.viewSession = null; this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null; this.pendingProofFiles = []; this.resetEntry(); this.joinLive(this.toIso(this.sessionDate)); this.mode = 'workspace'; } /** Existing date chosen on the landing screen — resolve it to its session and view it. */ openViewByDate(): void { const iso = this.toIso(this.sessionDate); this.api.getPaged(1, 1, iso, iso).subscribe({ next: r => { const item = r.items[0]; if (!item) { this.checkDate(); return; } // race: it vanished, refresh the flag this.openView(item); }, error: (err: { error?: { message?: string } }) => alert(err?.error?.message ?? 'Load failed.'), }); } /** Open a session read-only (from a Recent Sessions row or a resolved date). */ openView(s: OfferingSessionListItemDto): void { this.api.getById(s.id).subscribe({ next: dto => { this.viewSession = dto; this.joinLive(dto.sessionDate); this.mode = 'view'; }, error: (err: { error?: { message?: string } }) => alert(err?.error?.message ?? 'Load failed.'), }); } /** Edit button on the read-only view. Draft edits directly; Submitted confirms a reopen first. */ editFromView(): void { const dto = this.viewSession; if (!dto) return; if (dto.status === 'Draft') { this.loadIntoBuffer(dto); this.mode = 'workspace'; } else if (dto.status === 'Submitted') { this.confirmReopenOpen = true; } } /** Confirmed reopen of a Submitted session (status → Draft) then load it for editing. */ confirmReopen(): void { const dto = this.viewSession; if (!dto) { this.confirmReopenOpen = false; return; } this.api.reopen(dto.id).subscribe({ next: () => this.api.getById(dto.id).subscribe(fresh => { this.loadIntoBuffer(fresh); this.confirmReopenOpen = false; this.mode = 'workspace'; }), error: (err: { error?: { message?: string } }) => { this.confirmReopenOpen = false; alert(err?.error?.message ?? 'Reopen failed.'); }, }); } /** Leave workspace/view and return to the date-first landing screen. */ backToLanding(): void { this.leaveLive(); this.resetSession(); this.mode = 'landing'; this.loadSessions(); } private loadIntoBuffer(dto: OfferingSessionDto): void { this.editingSessionId = dto.id; this.sessionDate = new Date(dto.sessionDate + 'T00:00:00'); this.dateConflict = false; this.cashTotal = dto.cashTotal; this.checkTotal = dto.checkTotal; this.notes = dto.notes; this.pendingProofFiles = []; this.buffer = dto.givings.map(g => ({ memberId: g.memberId, givingCategoryId: g.givingCategoryId, amount: g.amount, paymentMethod: g.paymentMethod, checkNumber: g.checkNumber, zelleReferenceCode: g.zelleReferenceCode, payPalTransactionId: g.payPalTransactionId, isAnonymous: g.isAnonymous, notes: g.notes, memberName: g.memberName, categoryName: g.categoryName, })); this.resetEntry(); } onMemberFilter(term: string): void { if (!term) { this.memberResults = []; return; } this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe(r => this.memberResults = r.items.map((m: MemberListItemDto) => ({ id: m.id, displayName: memberDisplayName(m) }))); } 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; } clearAnonymous(): void { this.entry.isAnonymous = false; } lastAddedLine: OfferingBufferLine | null = null; addLine(): void { if (this.entry.amount <= 0) return; if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return; const cat = this.categories.find(c => c.id === this.entry.givingCategoryId); const line: OfferingBufferLine = { ...this.entry, memberName: this.entry.isAnonymous ? null : this.selectedMemberName, categoryName: cat?.label ?? '', }; if (this.editingIndex !== null) { this.buffer[this.editingIndex] = line; this.editingIndex = null; } else { this.buffer = [...this.buffer, line]; } this.lastAddedLine = line; this.resetEntry(); } editLine(i: number): void { const l = this.buffer[i]; this.entry = { ...l }; this.selectedMemberId = l.memberId; this.selectedMemberName = l.memberName; // Seed the dropdown so the giver name stays visible while editing the line. this.memberResults = (l.memberId && l.memberName) ? [{ id: l.memberId, displayName: l.memberName }] : []; this.editingIndex = i; } removeLine(i: number): void { this.buffer = this.buffer.filter((_, idx) => idx !== i); } onMemberQuickCreated(m: MemberListItemDto): void { this.showQuickAdd = false; const opt: MemberOption = { id: m.id, displayName: memberDisplayName(m) }; this.memberResults = [opt, ...this.memberResults.filter(x => x.id !== m.id)]; this.onMemberSelected(m.id); } submit(): void { if (this.buffer.length === 0 || (this.editingSessionId == null && this.dateConflict)) return; this.submitting = true; const req: CreateOfferingSessionRequest = { sessionDate: this.toIso(this.sessionDate), cashTotal: this.cashTotal, checkTotal: this.checkTotal, notes: this.notes, givings: this.buffer.map(l => ({ memberId: l.memberId, givingCategoryId: l.givingCategoryId, amount: l.amount, paymentMethod: l.paymentMethod, checkNumber: l.checkNumber, zelleReferenceCode: l.zelleReferenceCode, payPalTransactionId: l.payPalTransactionId, isAnonymous: l.isAnonymous, notes: l.notes, })), }; const isEdit = this.editingSessionId != null; // Save the session first, then resolve to its id so the proof PDF can be attached. const savedId$: Observable = isEdit ? this.api.replace(this.editingSessionId!, req).pipe(map(() => this.editingSessionId!)) : this.api.create(req).pipe(map(r => r.id)); savedId$.pipe( switchMap(id => this.pendingProofFiles.length === 0 ? of(void 0) : from(buildProofPdf(this.pendingProofFiles)).pipe( switchMap(({ blob }) => this.api.uploadProof(id, blob)))), ).subscribe({ next: () => { this.submitting = false; alert(isEdit ? 'Offering session updated.' : 'Offering session submitted.'); this.leaveLive(); this.resetSession(); this.mode = 'landing'; this.loadSessions(); }, error: (err: { error?: { message?: string } }) => { this.submitting = false; alert(err?.error?.message ?? 'Submit failed.'); }, }); } // ── Paper proof ─────────────────────────────────────────────────────────── /** Stage selected attachment files (appends, so multiple picks accumulate). */ onProofFilesSelected(event: Event): void { const input = event.target as HTMLInputElement; const files = Array.from(input.files ?? []); if (files.length) this.pendingProofFiles = [...this.pendingProofFiles, ...files]; input.value = ''; // allow re-selecting the same file } removeProofFile(i: number): void { this.pendingProofFiles = this.pendingProofFiles.filter((_, idx) => idx !== i); } /** Open the stored proof PDF in a new tab (authenticated blob fetch). */ openProof(id: number): void { this.api.downloadProof(id).subscribe(blob => { const url = URL.createObjectURL(blob); window.open(url, '_blank'); setTimeout(() => URL.revokeObjectURL(url), 60_000); }); } /** Replace the stored proof from the read-only view (rebuild + upload + refresh). */ replaceProof(event: Event): void { const input = event.target as HTMLInputElement; const files = Array.from(input.files ?? []); input.value = ''; if (!files.length || !this.viewSession) return; const id = this.viewSession.id; this.proofBusy = true; from(buildProofPdf(files)).pipe( switchMap(({ blob }) => this.api.uploadProof(id, blob)), switchMap(() => this.api.getById(id)), ).subscribe({ next: dto => { this.viewSession = dto; this.proofBusy = false; }, error: (err: { error?: { message?: string } }) => { this.proofBusy = false; alert(err?.error?.message ?? 'Proof upload failed.'); }, }); } removeProof(): void { if (!this.viewSession) return; if (!confirm('Remove the paper proof for this session? / 移除此 session 的紙本證明?')) return; const id = this.viewSession.id; this.proofBusy = true; this.api.deleteProof(id).pipe( switchMap(() => this.api.getById(id)), ).subscribe({ next: dto => { this.viewSession = dto; this.proofBusy = false; }, error: (err: { error?: { message?: string } }) => { this.proofBusy = false; alert(err?.error?.message ?? 'Remove failed.'); }, }); } /** Clear the whole working session back to a fresh state (today, empty buffer). */ private resetSession(): void { this.editingSessionId = null; this.editingIndex = null; this.viewSession = null; this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null; this.pendingProofFiles = []; this.sessionDate = new Date(); this.resetEntry(); this.checkDate(); } private resetEntry(): void { this.editingIndex = null; this.selectedMemberId = null; this.selectedMemberName = null; this.memberResults = []; this.entry = this.blankEntry(); this.entry.givingCategoryId = this.categories[0]?.id ?? 0; } private blankEntry(): OfferingBufferLine { return { memberId: null, givingCategoryId: this.lastAddedLine?.givingCategoryId, amount: 0, paymentMethod: this.lastAddedLine?.paymentMethod ?? 'Cash', checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null, isAnonymous: false, notes: null, memberName: null, categoryName: '', }; } // Format using LOCAL date components — NOT toISOString(), which converts to UTC and // rolls the date forward a day for behind-UTC users when the Date carries an evening time. 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}`; } }