From 7ab8e9703bac20534d546c039bdd1b098c85d94e Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Sat, 20 Jun 2026 21:06:24 -0700 Subject: [PATCH] WIP --- .../Controllers/OfferingEntryController.cs | 29 +++++++ .../DTOs/Giving/QuickAddMemberRequest.cs | 15 ++++ .../features/giving/models/giving.model.ts | 9 +++ .../offering-entry-mobile-page.component.html | 45 ++++++++++- .../offering-entry-mobile-page.component.scss | 14 +++- .../offering-entry-mobile-page.component.ts | 77 ++++++++++++++++++- .../services/offering-entry-api.service.ts | 6 ++ 7 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 API/ROLAC.API/DTOs/Giving/QuickAddMemberRequest.cs diff --git a/API/ROLAC.API/Controllers/OfferingEntryController.cs b/API/ROLAC.API/Controllers/OfferingEntryController.cs index b27790f..05185c7 100644 --- a/API/ROLAC.API/Controllers/OfferingEntryController.cs +++ b/API/ROLAC.API/Controllers/OfferingEntryController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using ROLAC.API.DTOs.Giving; +using ROLAC.API.DTOs.Members; using ROLAC.API.Hubs; using ROLAC.API.Services; @@ -20,15 +21,18 @@ public class OfferingEntryController : ControllerBase { private readonly IOfferingSessionService _sessions; private readonly IGivingCategoryService _categories; + private readonly IMemberService _members; private readonly IHubContext _hub; public OfferingEntryController( IOfferingSessionService sessions, IGivingCategoryService categories, + IMemberService members, IHubContext hub) { _sessions = sessions; _categories = categories; + _members = members; _hub = hub; } @@ -47,6 +51,31 @@ public class OfferingEntryController : ControllerBase public async Task SearchMembers([FromQuery] string? search, [FromQuery] int take = 10) => Ok(await _sessions.SearchMembersForEntryAsync(search, Math.Clamp(take, 1, 25))); + // Quick-add a giver who isn't on file yet (created as a Visitor). Reuses the + // member service directly — role checks live on MembersController, so this + // anonymous path is the intended public entry point for the mobile page. + [HttpPost("members")] + public async Task QuickAddMember([FromBody] QuickAddMemberRequest request) + { + var id = await _members.CreateAsync(new CreateMemberRequest + { + FirstName_en = request.FirstName_en, + LastName_en = request.LastName_en, + NickName = request.NickName, + FirstName_zh = request.FirstName_zh, + LastName_zh = request.LastName_zh, + PhoneCell = request.PhoneCell, + Status = "Visitor", + Country = "USA", + LanguagePreference = "en", + }); + return Ok(new MemberTypeaheadDto + { + Id = id, NickName = request.NickName, + FirstName_en = request.FirstName_en, LastName_en = request.LastName_en, + }); + } + // Append one offering line to the date's session (find-or-create), then // broadcast it to everyone viewing that date. [HttpPost("lines")] diff --git a/API/ROLAC.API/DTOs/Giving/QuickAddMemberRequest.cs b/API/ROLAC.API/DTOs/Giving/QuickAddMemberRequest.cs new file mode 100644 index 0000000..c9cd3c5 --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/QuickAddMemberRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Giving; + +// Minimal member fields the mobile offering-entry page collects when a giver +// isn't on file yet. Creates a Visitor; the rest of the profile can be filled +// in later from the admin Members page. +public class QuickAddMemberRequest +{ + [Required, MaxLength(100)] public string FirstName_en { get; set; } = ""; + [Required, MaxLength(100)] public string LastName_en { get; set; } = ""; + [MaxLength(100)] public string? NickName { get; set; } + [MaxLength(100)] public string? FirstName_zh { get; set; } + [MaxLength(100)] public string? LastName_zh { get; set; } + [MaxLength(30)] public string? PhoneCell { get; set; } +} diff --git a/APP/src/app/features/giving/models/giving.model.ts b/APP/src/app/features/giving/models/giving.model.ts index 273d0a9..b4e1a69 100644 --- a/APP/src/app/features/giving/models/giving.model.ts +++ b/APP/src/app/features/giving/models/giving.model.ts @@ -150,6 +150,15 @@ export interface AppendOfferingLineRequest { date: string; // yyyy-MM-dd line: OfferingGivingLineRequest; } +/** Body of POST /api/offering-entry/members — quick-add a giver (created as Visitor). */ +export interface QuickAddMemberRequest { + firstName_en: string; + lastName_en: string; + nickName: string | null; + firstName_zh: string | null; + lastName_zh: string | null; + phoneCell: string | null; +} /** Returned from append + broadcast over the OfferingEntryHub. */ export interface OfferingEntryLineAddedDto { sessionId: number; diff --git a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html index 090ab85..2bbe8d8 100644 --- a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html +++ b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html @@ -40,8 +40,12 @@ (click)="clearAnonymous()">改回填寫 · Clear - +
+ + +
@@ -93,4 +97,41 @@
{{ toast }}
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
diff --git a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss index b31f245..08338d1 100644 --- a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss +++ b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss @@ -170,9 +170,19 @@ background: #e0e7ff; } -.oe__anon-btn { +.oe__giver-actions { margin-top: 0.1rem; - align-self: flex-start; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +/* Quick-add dialog body: stacked fields, scrollable on short screens. */ +.oe__qa { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 0.25rem 0.1rem; } /* ── Sticky submit bar ──────────────────────────────────────────────────── */ diff --git a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts index 4208696..955ad7e 100644 --- a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts +++ b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts @@ -5,9 +5,12 @@ import { Subject, takeUntil } from 'rxjs'; 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 { OfferingEntryApiService } from '../../services/offering-entry-api.service'; import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service'; -import { GivingCategoryDto, OfferingGivingLineRequest, MemberTypeaheadDto } from '../../models/giving.model'; +import { + GivingCategoryDto, OfferingGivingLineRequest, MemberTypeaheadDto, QuickAddMemberRequest, +} from '../../models/giving.model'; import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists'; interface MemberOption { id: number; displayName: string; } @@ -21,7 +24,7 @@ interface MemberOption { id: number; displayName: string; } @Component({ selector: 'app-offering-entry-mobile-page', standalone: true, - imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DropDownsModule], + imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DropDownsModule, DialogsModule], templateUrl: './offering-entry-mobile-page.component.html', styleUrls: ['./offering-entry-mobile-page.component.scss'], }) @@ -48,6 +51,11 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy { toast: string | null = null; connected = false; + // Quick-add dialog for a giver who isn't on file yet. + showQuickAdd = false; + quickAddSaving = false; + quickAdd: QuickAddMemberRequest = this.blankQuickAdd(); + private toastTimer?: ReturnType; private readonly destroy$ = new Subject(); @@ -144,6 +152,66 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy { this.entry.isAnonymous = false; } + // ── Quick-add member ──────────────────────────────────────────────────────── + + openQuickAdd(): void { + this.quickAdd = this.blankQuickAdd(); + this.showQuickAdd = true; + } + + cancelQuickAdd(): void { + this.showQuickAdd = false; + } + + get canSaveQuickAdd(): boolean { + return !this.quickAddSaving + && !!this.quickAdd.firstName_en?.trim() + && !!this.quickAdd.lastName_en?.trim(); + } + + saveQuickAdd(): void { + if (!this.canSaveQuickAdd) { + return; + } + this.quickAddSaving = true; + const request: QuickAddMemberRequest = { + firstName_en: this.quickAdd.firstName_en.trim(), + lastName_en: this.quickAdd.lastName_en.trim(), + nickName: this.trimToNull(this.quickAdd.nickName), + firstName_zh: this.trimToNull(this.quickAdd.firstName_zh), + lastName_zh: this.trimToNull(this.quickAdd.lastName_zh), + phoneCell: this.trimToNull(this.quickAdd.phoneCell), + }; + this.api.quickAddMember(request).subscribe({ + next: created => { + this.quickAddSaving = false; + this.showQuickAdd = false; + // Seed the new member into the typeahead and select them immediately. + const option = { id: created.id, displayName: this.giverLabel(created) }; + this.memberResults = [option, ...this.memberResults.filter(m => m.id !== created.id)]; + this.entry.isAnonymous = false; + this.onMemberSelected(created.id); + this.showToast('已新增會友 ✓ Member added'); + }, + error: (err: { error?: { message?: string } }) => { + this.quickAddSaving = false; + this.showToast(err?.error?.message ?? '新增失敗 Add failed'); + }, + }); + } + + private blankQuickAdd(): QuickAddMemberRequest { + return { + firstName_en: '', lastName_en: '', nickName: null, + firstName_zh: null, lastName_zh: null, phoneCell: null, + }; + } + + private trimToNull(value: string | null): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; + } + submit(): void { if (!this.canSubmit) { return; @@ -185,8 +253,13 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy { private resetForm(): void { const defaultCategory = this.categories[0]?.id ?? 0; + // Keep the last-used payment method — a counter usually enters several gifts + // of the same method in a row, so they don't have to re-pick it each time. + // The per-gift reference fields (check #, Zelle, PayPal) still clear below. + const keepMethod = this.entry.paymentMethod; this.entry = this.blankEntry(); this.entry.givingCategoryId = defaultCategory; + this.entry.paymentMethod = keepMethod; this.selectedMemberId = null; this.selectedMemberName = null; this.memberResults = []; diff --git a/APP/src/app/features/giving/services/offering-entry-api.service.ts b/APP/src/app/features/giving/services/offering-entry-api.service.ts index 2fe12dd..d1bb7dc 100644 --- a/APP/src/app/features/giving/services/offering-entry-api.service.ts +++ b/APP/src/app/features/giving/services/offering-entry-api.service.ts @@ -6,6 +6,7 @@ import { ApiConfigService } from '../../../core/services/api-config.service'; import { OfferingEntryBootstrapDto, OfferingEntryLineAddedDto, AppendOfferingLineRequest, OfferingGivingLineRequest, MemberTypeaheadDto, + QuickAddMemberRequest, } from '../models/giving.model'; /** @@ -39,4 +40,9 @@ export class OfferingEntryApiService { const body: AppendOfferingLineRequest = { date, line }; return this.http.post(`${this.endpoint}/lines`, body); } + + /** Quick-add a giver not yet on file (created as a Visitor). */ + quickAddMember(request: QuickAddMemberRequest): Observable { + return this.http.post(`${this.endpoint}/members`, request); + } }