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:
+170
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user