feat(giving): single giving entry page
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
<div class="page">
|
||||||
|
<header class="page-header">
|
||||||
|
<h2>Givings / 單筆奉獻</h2>
|
||||||
|
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add Giving</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<kendo-textbox placeholder="Search check # / notes" [(ngModel)]="search" (keydown.enter)="onSearch()"></kendo-textbox>
|
||||||
|
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id"
|
||||||
|
[valuePrimitive]="true" [(ngModel)]="filterCategoryId" (valueChange)="onSearch()"
|
||||||
|
[defaultItem]="{ id: null, name_en: 'All types' }"></kendo-dropdownlist>
|
||||||
|
<button kendoButton (click)="onSearch()">Search</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<kendo-grid [data]="data" [loading]="isLoading"
|
||||||
|
[pageable]="true" [skip]="(page-1)*pageSize" [pageSize]="pageSize"
|
||||||
|
[total]="totalCount" (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>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column field="categoryName" title="Type"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="paymentMethod" title="Method" [width]="100"></kendo-grid-column>
|
||||||
|
<kendo-grid-column title="Actions" [width]="120">
|
||||||
|
<ng-template kendoGridCellTemplate let-g>
|
||||||
|
<button kendoButton fillMode="flat" (click)="delete(g)">Delete</button>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
</kendo-grid>
|
||||||
|
|
||||||
|
<kendo-dialog *ngIf="showDialog" title="Add Giving" (close)="showDialog=false" [width]="520">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" [ngModel]="form.isAnonymous" (ngModelChange)="toggleAnonymous()" /> Anonymous
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label *ngIf="!form.isAnonymous">Giver
|
||||||
|
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id"
|
||||||
|
[valuePrimitive]="true" [filterable]="true"
|
||||||
|
(filterChange)="onMemberFilter($event)"
|
||||||
|
[(ngModel)]="selectedMemberId"
|
||||||
|
(valueChange)="onMemberIdSelected($event)"
|
||||||
|
placeholder="Search member by name"></kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Type
|
||||||
|
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id"
|
||||||
|
[valuePrimitive]="true" [(ngModel)]="form.givingCategoryId"></kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Payment method
|
||||||
|
<kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="form.paymentMethod"></kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label *ngIf="form.paymentMethod === 'Check'">Check #
|
||||||
|
<kendo-textbox [(ngModel)]="form.checkNumber"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label *ngIf="form.paymentMethod === 'Zelle'">Zelle ref
|
||||||
|
<kendo-textbox [(ngModel)]="form.zelleReferenceCode"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label *ngIf="form.paymentMethod === 'PayPal'">PayPal txn
|
||||||
|
<kendo-textbox [(ngModel)]="form.payPalTransactionId"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Amount
|
||||||
|
<kendo-numerictextbox [(ngModel)]="form.amount" [min]="0" [format]="'c2'"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Date
|
||||||
|
<kendo-datepicker [(ngModel)]="givingDateValue"></kendo-datepicker>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Notes
|
||||||
|
<kendo-textbox [(ngModel)]="form.notes"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="showDialog=false">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary"
|
||||||
|
[disabled]="form.amount <= 0 || (form.paymentMethod==='Check' && !form.checkNumber)"
|
||||||
|
(click)="save()">Save</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
||||||
|
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||||
|
.form-grid { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.form-grid label { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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 { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
|
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||||
|
import { GivingApiService } from '../../services/giving-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 {
|
||||||
|
GivingListItemDto, GivingCategoryDto, CreateGivingRequest, PaymentMethod, PagedResult,
|
||||||
|
} from '../../models/giving.model';
|
||||||
|
|
||||||
|
/** Flattened member item with a single displayName field for the dropdown. */
|
||||||
|
interface MemberOption {
|
||||||
|
id: number;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-givings-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
|
||||||
|
DropDownsModule, DialogsModule, DateInputsModule,
|
||||||
|
],
|
||||||
|
templateUrl: './givings-page.component.html',
|
||||||
|
styleUrls: ['./givings-page.component.scss'],
|
||||||
|
})
|
||||||
|
export class GivingsPageComponent implements OnInit {
|
||||||
|
data: GivingListItemDto[] = [];
|
||||||
|
totalCount = 0;
|
||||||
|
page = 1;
|
||||||
|
pageSize = 20;
|
||||||
|
isLoading = false;
|
||||||
|
|
||||||
|
search = '';
|
||||||
|
filterCategoryId: number | null = null;
|
||||||
|
categories: GivingCategoryDto[] = [];
|
||||||
|
|
||||||
|
readonly paymentMethods: PaymentMethod[] = ['Cash', 'Check', 'Zelle', 'PayPal', 'Other'];
|
||||||
|
|
||||||
|
memberResults: MemberOption[] = [];
|
||||||
|
|
||||||
|
showDialog = false;
|
||||||
|
editingId: number | null = null;
|
||||||
|
form: CreateGivingRequest = this.blankForm();
|
||||||
|
selectedMemberId: number | null = null;
|
||||||
|
|
||||||
|
/** Separate Date field for kendo-datepicker (which binds Date, not string). */
|
||||||
|
givingDateValue: Date = new Date();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private api: GivingApiService,
|
||||||
|
private categoryApi: GivingCategoryApiService,
|
||||||
|
private memberApi: MemberApiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.categoryApi.getAll(false).subscribe(c => this.categories = c);
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.api.getPaged({
|
||||||
|
page: this.page, pageSize: this.pageSize,
|
||||||
|
search: this.search || undefined,
|
||||||
|
categoryId: this.filterCategoryId ?? undefined,
|
||||||
|
}).subscribe({
|
||||||
|
next: (r: PagedResult<GivingListItemDto>) => {
|
||||||
|
this.data = r.items; this.totalCount = r.totalCount; this.isLoading = false;
|
||||||
|
},
|
||||||
|
error: () => { this.isLoading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(e: PageChangeEvent): void {
|
||||||
|
this.page = e.skip / this.pageSize + 1; this.pageSize = e.take; this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearch(): void { this.page = 1; this.load(); }
|
||||||
|
|
||||||
|
onMemberFilter(term: string): void {
|
||||||
|
if (!term || term.length < 1) { 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),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openAdd(): void {
|
||||||
|
this.editingId = null;
|
||||||
|
this.form = this.blankForm();
|
||||||
|
this.selectedMemberId = null;
|
||||||
|
this.givingDateValue = new Date();
|
||||||
|
this.memberResults = [];
|
||||||
|
this.showDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called from template valueChange — receives the primitive id (number | null). */
|
||||||
|
onMemberIdSelected(id: number | null): void {
|
||||||
|
this.selectedMemberId = id ?? null;
|
||||||
|
this.form.memberId = this.selectedMemberId;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleAnonymous(): void {
|
||||||
|
this.form.isAnonymous = !this.form.isAnonymous;
|
||||||
|
if (this.form.isAnonymous) {
|
||||||
|
this.form.memberId = null;
|
||||||
|
this.selectedMemberId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
// Sync the datepicker Date back to the ISO string field.
|
||||||
|
this.form.givingDate = this.givingDateValue
|
||||||
|
? this.givingDateValue.toISOString().slice(0, 10)
|
||||||
|
: new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (this.editingId) {
|
||||||
|
this.api.update(this.editingId, this.form).subscribe(() => { this.showDialog = false; this.load(); });
|
||||||
|
} else {
|
||||||
|
this.api.create(this.form).subscribe(() => { this.showDialog = false; this.load(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(g: GivingListItemDto): void {
|
||||||
|
if (!confirm('Delete this giving record?')) return;
|
||||||
|
this.api.delete(g.id).subscribe({
|
||||||
|
next: () => this.load(),
|
||||||
|
error: (err: { error?: { message?: string } }) =>
|
||||||
|
alert(err?.error?.message ?? 'Delete failed (record may belong to a locked session).'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private blankForm(): CreateGivingRequest {
|
||||||
|
return {
|
||||||
|
memberId: null,
|
||||||
|
givingCategoryId: this.categories[0]?.id ?? 0,
|
||||||
|
amount: 0,
|
||||||
|
paymentMethod: 'Cash',
|
||||||
|
checkNumber: null,
|
||||||
|
zelleReferenceCode: null,
|
||||||
|
payPalTransactionId: null,
|
||||||
|
givingDate: new Date().toISOString().slice(0, 10),
|
||||||
|
isAnonymous: false,
|
||||||
|
notes: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user