Files
ROLAC/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts
T
Chris Chen 0639d1fe83 WIP
2026-05-28 22:29:13 -07:00

248 lines
9.3 KiB
TypeScript

import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Observable } from 'rxjs';
import { GridModule } 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 { OfferingSessionApiService } from '../../services/offering-session-api.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,
} from '../../models/giving.model';
interface MemberOption { id: number; displayName: string; }
@Component({
selector: 'app-offering-session-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
DropDownsModule, DateInputsModule, MemberQuickAddDialogComponent,
],
templateUrl: './offering-session-page.component.html',
styleUrls: ['./offering-session-page.component.scss'],
})
export class OfferingSessionPageComponent implements OnInit {
sessionDate: Date = new Date();
dateConflict = false;
categories: GivingCategoryDto[] = [];
readonly paymentMethods: PaymentMethod[] = ['Cash', 'Check', 'Zelle', 'PayPal', 'Other'];
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;
sessions: OfferingSessionListItemDto[] = [];
editingSessionId: number | null = null;
constructor(
private api: OfferingSessionApiService,
private categoryApi: GivingCategoryApiService,
private memberApi: MemberApiService,
) {}
ngOnInit(): void {
this.categoryApi.getAll(false).subscribe(c => {
this.categories = c;
this.entry.givingCategoryId = c[0]?.id ?? 0;
});
this.checkDate();
this.loadSessions();
}
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);
}
reopenAndEdit(s: OfferingSessionListItemDto): void {
if (s.status !== 'Submitted') return;
this.api.reopen(s.id).subscribe({
next: () => this.api.getById(s.id).subscribe(dto => this.loadIntoBuffer(dto)),
error: (err: { error?: { message?: string } }) => alert(err?.error?.message ?? 'Reopen failed.'),
});
}
// Already a Draft (e.g. a session reopened then left) — load it straight back in, no reopen.
continueEditDraft(s: OfferingSessionListItemDto): void {
if (s.status !== 'Draft') return;
this.api.getById(s.id).subscribe({
next: dto => this.loadIntoBuffer(dto),
error: (err: { error?: { message?: string } }) => alert(err?.error?.message ?? 'Load failed.'),
});
}
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.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();
}
cancelEdit(): void {
this.editingSessionId = null;
this.editingIndex = null;
this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
this.sessionDate = new Date();
this.checkDate();
// The reopened session is now a server-side Draft — refresh so its "Continue editing" appears.
this.loadSessions();
}
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;
}
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?.name_en ?? '',
};
if (this.editingIndex !== null) { this.buffer[this.editingIndex] = line; this.editingIndex = null; }
else { this.buffer = [...this.buffer, 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 obs: Observable<unknown> = this.editingSessionId != null
? this.api.replace(this.editingSessionId, req)
: this.api.create(req);
obs.subscribe({
next: () => {
this.submitting = false;
alert(this.editingSessionId != null ? 'Offering session updated.' : 'Offering session submitted.');
this.editingSessionId = null;
this.editingIndex = null;
this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
this.sessionDate = new Date();
this.checkDate();
this.loadSessions();
},
error: (err: { error?: { message?: string } }) => {
this.submitting = false;
alert(err?.error?.message ?? 'Submit failed.');
},
});
}
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: 0, amount: 0, 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}`;
}
}