522 lines
20 KiB
TypeScript
522 lines
20 KiB
TypeScript
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<void>();
|
|
|
|
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<number> = 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}`;
|
|
}
|
|
}
|