feat(giving): keyboard-first Sunday offering batch entry page + routes

- Add MemberQuickAddDialogComponent for fast in-session member creation
- Add OfferingSessionPageComponent: client-side buffer, reconcile totals, date-conflict check, submit to API
- Wire finance/giving-categories, finance/givings, finance/offering-session routes (RoleGuard: finance + super_admin)
- Fix givings-page: replace [total] + data[] with GridDataResult for Kendo v20 server-side paging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-28 17:14:56 -07:00
parent 81a0b5a038
commit 001db35cef
8 changed files with 364 additions and 6 deletions
@@ -0,0 +1,170 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
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,
} 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;
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();
}
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);
}
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;
}
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;
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.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,
})),
};
this.api.create(req).subscribe({
next: () => {
this.submitting = false;
alert('Offering session submitted.');
this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
this.checkDate();
},
error: (err: { error?: { message?: string } }) => {
this.submitting = false;
alert(err?.error?.message ?? 'Submit failed.');
},
});
}
private resetEntry(): void {
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: '',
};
}
private toIso(d: Date): string { return d.toISOString().slice(0, 10); }
}