From 001db35cef731193c2c44ccbe8c22bb8ee6b15d2 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 28 May 2026 17:14:56 -0700 Subject: [PATCH] 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 --- APP/src/app/app.routes.ts | 21 +++ .../member-quick-add-dialog.component.html | 13 ++ .../member-quick-add-dialog.component.ts | 76 ++++++++ .../givings-page/givings-page.component.html | 4 +- .../givings-page/givings-page.component.ts | 7 +- .../offering-session-page.component.html | 70 ++++++++ .../offering-session-page.component.scss | 9 + .../offering-session-page.component.ts | 170 ++++++++++++++++++ 8 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.html create mode 100644 APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts create mode 100644 APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html create mode 100644 APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss create mode 100644 APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index 2b02596..f9d6097 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -6,6 +6,9 @@ import { AuthGuard } from './core/guards/auth.guard'; import { RoleGuard } from './core/guards/role.guard'; import { MembersPageComponent } from './features/members/pages/members-page/members-page.component'; import { UsersPageComponent } from './features/users/pages/users-page/users-page.component'; +import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component'; +import { GivingsPageComponent } from './features/giving/pages/givings-page/givings-page.component'; +import { OfferingSessionPageComponent } from './features/giving/pages/offering-session-page/offering-session-page.component'; export const routes: Routes = [ // Public routes @@ -26,6 +29,24 @@ export const routes: Routes = [ canActivate: [RoleGuard], data: { roles: ['super_admin'] }, }, + { + path: 'finance/giving-categories', + component: GivingCategoriesPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, + { + path: 'finance/givings', + component: GivingsPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, + { + path: 'finance/offering-session', + component: OfferingSessionPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, ] }, diff --git a/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.html b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.html new file mode 100644 index 0000000..5e5f1c1 --- /dev/null +++ b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.html @@ -0,0 +1,13 @@ + +
+ + + + + +
+ + + + +
diff --git a/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts new file mode 100644 index 0000000..caf7de5 --- /dev/null +++ b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts @@ -0,0 +1,76 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { MemberApiService } from '../../../members/services/member-api.service'; +import { CreateMemberRequest, MemberListItemDto } from '../../../members/models/member.model'; + +@Component({ + selector: 'app-member-quick-add-dialog', + standalone: true, + imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule], + templateUrl: './member-quick-add-dialog.component.html', +}) +export class MemberQuickAddDialogComponent { + @Output() created = new EventEmitter(); + @Output() cancelled = new EventEmitter(); + + firstName_en = ''; + lastName_en = ''; + firstName_zh: string | null = null; + lastName_zh: string | null = null; + phoneCell: string | null = null; + saving = false; + + constructor(private memberApi: MemberApiService) {} + + save(): void { + if (!this.firstName_en || !this.lastName_en) return; + this.saving = true; + const req: CreateMemberRequest = { + firstName_en: this.firstName_en, + lastName_en: this.lastName_en, + nickName: null, + firstName_zh: this.firstName_zh, + lastName_zh: this.lastName_zh, + gender: null, + dateOfBirth: null, + baptismDate: null, + baptismChurch: null, + email: null, + phoneCell: this.phoneCell, + phoneHome: null, + address: null, + city: null, + state: null, + zipCode: null, + country: 'USA', + status: 'Visitor', + languagePreference: 'en', + joinDate: null, + notes: null, + familyUnitId: null, + }; + this.memberApi.create(req).subscribe({ + next: ({ id }) => { + this.saving = false; + this.created.emit({ + id, + firstName_en: this.firstName_en, + lastName_en: this.lastName_en, + nickName: null, + firstName_zh: this.firstName_zh, + lastName_zh: this.lastName_zh, + status: 'Visitor', + email: null, + phoneCell: this.phoneCell, + joinDate: null, + linkedUserId: null, + }); + }, + error: () => { this.saving = false; }, + }); + } +} diff --git a/APP/src/app/features/giving/pages/givings-page/givings-page.component.html b/APP/src/app/features/giving/pages/givings-page/givings-page.component.html index b04f2d0..467d23f 100644 --- a/APP/src/app/features/giving/pages/givings-page/givings-page.component.html +++ b/APP/src/app/features/giving/pages/givings-page/givings-page.component.html @@ -12,9 +12,9 @@ - + (pageChange)="onPageChange($event)"> {{ g.isAnonymous ? '(Anonymous)' : g.memberName }} diff --git a/APP/src/app/features/giving/pages/givings-page/givings-page.component.ts b/APP/src/app/features/giving/pages/givings-page/givings-page.component.ts index a17c289..3b333d5 100644 --- a/APP/src/app/features/giving/pages/givings-page/givings-page.component.ts +++ b/APP/src/app/features/giving/pages/givings-page/givings-page.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid'; +import { GridModule, GridDataResult, PageChangeEvent } 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'; @@ -32,8 +32,7 @@ interface MemberOption { styleUrls: ['./givings-page.component.scss'], }) export class GivingsPageComponent implements OnInit { - data: GivingListItemDto[] = []; - totalCount = 0; + gridData: GridDataResult = { data: [], total: 0 }; page = 1; pageSize = 20; isLoading = false; @@ -73,7 +72,7 @@ export class GivingsPageComponent implements OnInit { categoryId: this.filterCategoryId ?? undefined, }).subscribe({ next: (r: PagedResult) => { - this.data = r.items; this.totalCount = r.totalCount; this.isLoading = false; + this.gridData = { data: r.items, total: r.totalCount }; this.isLoading = false; }, error: () => { this.isLoading = false; }, }); diff --git a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html new file mode 100644 index 0000000..151bae4 --- /dev/null +++ b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html @@ -0,0 +1,70 @@ +
+ + +
+ An offering session for this date already exists. Pick another date, or reopen the existing session to edit. +
+ +
+ + Anonymous + + + + + + + +
+ + + +
+
+ + + + {{ l.isAnonymous ? '(Anonymous)' : l.memberName }} + + + + + + + + + + + + + +
+
Lines: {{ buffer.length }} | System total: {{ systemTotal | currency }}
+ + +
Difference: {{ difference | currency }}
+ +
+ + +
diff --git a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss new file mode 100644 index 0000000..c0e033f --- /dev/null +++ b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss @@ -0,0 +1,9 @@ +.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } +.warn { background: #fff3cd; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; } +.entry-row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; margin-bottom: 1rem; } +.entry-row label { display: flex; flex-direction: column; gap: 0.25rem; } +.entry-actions { display: flex; gap: 0.5rem; } +.anon-chip { padding: 0.25rem 0.5rem; background: #eee; border-radius: 4px; } +.reconcile { display: flex; gap: 1rem; align-items: flex-end; margin-top: 1rem; } +.reconcile .ok { color: green; font-weight: 600; } +.reconcile .bad { color: #c00; font-weight: 600; } diff --git a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts new file mode 100644 index 0000000..c0c1a07 --- /dev/null +++ b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts @@ -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); } +}