+
+
+
+ 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); }
+}