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
+21
View File
@@ -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'] },
},
]
},
@@ -0,0 +1,13 @@
<kendo-dialog title="Quick add member" (close)="cancelled.emit()" [width]="420">
<div style="display:flex;flex-direction:column;gap:0.75rem;">
<label>First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label>
<label>Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label>
<label>名 (中)<kendo-textbox [(ngModel)]="firstName_zh"></kendo-textbox></label>
<label>姓 (中)<kendo-textbox [(ngModel)]="lastName_zh"></kendo-textbox></label>
<label>Cell phone<kendo-textbox [(ngModel)]="phoneCell"></kendo-textbox></label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="cancelled.emit()">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!firstName_en || !lastName_en || saving" (click)="save()">Create</button>
</kendo-dialog-actions>
</kendo-dialog>
@@ -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<MemberListItemDto>();
@Output() cancelled = new EventEmitter<void>();
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; },
});
}
}
@@ -12,9 +12,9 @@
<button kendoButton (click)="onSearch()">Search</button>
</div>
<kendo-grid [data]="data" [loading]="isLoading"
<kendo-grid [data]="gridData" [loading]="isLoading"
[pageable]="true" [skip]="(page-1)*pageSize" [pageSize]="pageSize"
[total]="totalCount" (pageChange)="onPageChange($event)">
(pageChange)="onPageChange($event)">
<kendo-grid-column field="givingDate" title="Date" [width]="110"></kendo-grid-column>
<kendo-grid-column title="Giver">
<ng-template kendoGridCellTemplate let-g>{{ g.isAnonymous ? '(Anonymous)' : g.memberName }}</ng-template>
@@ -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<GivingListItemDto>) => {
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; },
});
@@ -0,0 +1,70 @@
<div class="page">
<header class="page-header">
<h2>Sunday Offering Entry / 主日奉獻錄入</h2>
<label>Date
<kendo-datepicker [(ngModel)]="sessionDate" (valueChange)="checkDate()"></kendo-datepicker>
</label>
</header>
<div *ngIf="dateConflict" class="warn">
An offering session for this date already exists. Pick another date, or reopen the existing session to edit.
</div>
<section class="entry-row">
<label *ngIf="!entry.isAnonymous">Giver
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id"
[valuePrimitive]="true" [filterable]="true"
(filterChange)="onMemberFilter($event)" [(ngModel)]="selectedMemberId"
(valueChange)="onMemberSelected($event)" placeholder="Search by name"></kendo-dropdownlist>
</label>
<span *ngIf="entry.isAnonymous" class="anon-chip">Anonymous</span>
<label>Type
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id"
[valuePrimitive]="true" [(ngModel)]="entry.givingCategoryId"></kendo-dropdownlist>
</label>
<label>Method
<kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="entry.paymentMethod"></kendo-dropdownlist>
</label>
<label *ngIf="entry.paymentMethod === 'Check'">Check #<kendo-textbox [(ngModel)]="entry.checkNumber"></kendo-textbox></label>
<label>Amount
<kendo-numerictextbox [(ngModel)]="entry.amount" [min]="0" [format]="'c2'" (keydown.enter)="addLine()"></kendo-numerictextbox>
</label>
<label>Notes<kendo-textbox [(ngModel)]="entry.notes" (keydown.enter)="addLine()"></kendo-textbox></label>
<div class="entry-actions">
<button kendoButton (click)="markAnonymous()">Anonymous</button>
<button kendoButton (click)="showQuickAdd = true">+ Quick add member</button>
<button kendoButton themeColor="primary" (click)="addLine()">+ Add (Enter)</button>
</div>
</section>
<kendo-grid [data]="buffer">
<kendo-grid-column title="Giver">
<ng-template kendoGridCellTemplate let-l>{{ l.isAnonymous ? '(Anonymous)' : l.memberName }}</ng-template>
</kendo-grid-column>
<kendo-grid-column field="categoryName" title="Type"></kendo-grid-column>
<kendo-grid-column field="paymentMethod" title="Method" [width]="90"></kendo-grid-column>
<kendo-grid-column field="checkNumber" title="Check #" [width]="90"></kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column title="" [width]="120">
<ng-template kendoGridCellTemplate let-l let-i="rowIndex">
<button kendoButton fillMode="flat" (click)="editLine(i)">Edit</button>
<button kendoButton fillMode="flat" (click)="removeLine(i)">×</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<section class="reconcile">
<div>Lines: {{ buffer.length }} | System total: {{ systemTotal | currency }}</div>
<label>Cash counted<kendo-numerictextbox [(ngModel)]="cashTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox></label>
<label>Check counted<kendo-numerictextbox [(ngModel)]="checkTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox></label>
<div [class.ok]="difference === 0" [class.bad]="difference !== 0">Difference: {{ difference | currency }}</div>
<button kendoButton themeColor="primary"
[disabled]="buffer.length === 0 || dateConflict || submitting" (click)="submit()">Submit</button>
</section>
<app-member-quick-add-dialog *ngIf="showQuickAdd"
(created)="onMemberQuickCreated($event)"
(cancelled)="showQuickAdd = false"></app-member-quick-add-dialog>
</div>
@@ -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; }
@@ -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); }
}