This commit is contained in:
Chris Chen
2026-06-20 21:06:24 -07:00
parent aaaae09bd2
commit 7ab8e9703b
7 changed files with 189 additions and 6 deletions
@@ -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<OfferingEntryHub> _hub;
public OfferingEntryController(
IOfferingSessionService sessions,
IGivingCategoryService categories,
IMemberService members,
IHubContext<OfferingEntryHub> hub)
{
_sessions = sessions;
_categories = categories;
_members = members;
_hub = hub;
}
@@ -47,6 +51,31 @@ public class OfferingEntryController : ControllerBase
public async Task<IActionResult> 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<IActionResult> 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")]
@@ -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; }
}
@@ -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;
@@ -40,8 +40,12 @@
(click)="clearAnonymous()">改回填寫 · Clear</button>
</div>
<button *ngIf="!entry.isAnonymous" kendoButton fillMode="outline" size="large"
class="oe__anon-btn" (click)="markAnonymous()">設為匿名 · Anonymous</button>
<div *ngIf="!entry.isAnonymous" class="oe__giver-actions">
<button kendoButton fillMode="outline" size="large"
(click)="openQuickAdd()"> 快速新增 · Quick add</button>
<button kendoButton fillMode="outline" size="large"
(click)="markAnonymous()">設為匿名 · Anonymous</button>
</div>
</div>
<div class="oe__field">
@@ -93,4 +97,41 @@
</div>
<div *ngIf="toast" class="oe__toast">{{ toast }}</div>
<!-- Quick-add member -->
<kendo-dialog *ngIf="showQuickAdd" title="快速新增會友 · Quick add member"
(close)="cancelQuickAdd()" [minWidth]="280" [width]="360">
<div class="oe__qa">
<div class="oe__field">
<label class="oe__label">英文名 · Legal first name *</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.firstName_en" size="large"></kendo-textbox>
</div>
<div class="oe__field">
<label class="oe__label">英文姓 · Last name *</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.lastName_en" size="large"></kendo-textbox>
</div>
<div class="oe__field">
<label class="oe__label">暱稱 · Nick name</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.nickName" size="large"></kendo-textbox>
</div>
<div class="oe__field">
<label class="oe__label">中文名 · Chinese first name</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.firstName_zh" size="large"></kendo-textbox>
</div>
<div class="oe__field">
<label class="oe__label">中文姓 · Chinese last name</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.lastName_zh" size="large"></kendo-textbox>
</div>
<div class="oe__field">
<label class="oe__label">手機 · Cell phone</label>
<kendo-textbox class="oe__control" [(ngModel)]="quickAdd.phoneCell" size="large"></kendo-textbox>
</div>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="cancelQuickAdd()">取消 · Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!canSaveQuickAdd" (click)="saveQuickAdd()">
{{ quickAddSaving ? '新增中… · Saving' : '新增 · Add' }}
</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
@@ -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 ──────────────────────────────────────────────────── */
@@ -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<typeof setTimeout>;
private readonly destroy$ = new Subject<void>();
@@ -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 = [];
@@ -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<OfferingEntryLineAddedDto>(`${this.endpoint}/lines`, body);
}
/** Quick-add a giver not yet on file (created as a Visitor). */
quickAddMember(request: QuickAddMemberRequest): Observable<MemberTypeaheadDto> {
return this.http.post<MemberTypeaheadDto>(`${this.endpoint}/members`, request);
}
}