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:
@@ -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'] },
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
+13
@@ -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>
|
||||
+76
@@ -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; },
|
||||
});
|
||||
|
||||
+70
@@ -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>
|
||||
+9
@@ -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; }
|
||||
+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