WIP
This commit is contained in:
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using ROLAC.API.DTOs.Giving;
|
using ROLAC.API.DTOs.Giving;
|
||||||
|
using ROLAC.API.DTOs.Members;
|
||||||
using ROLAC.API.Hubs;
|
using ROLAC.API.Hubs;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -20,15 +21,18 @@ public class OfferingEntryController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly IOfferingSessionService _sessions;
|
private readonly IOfferingSessionService _sessions;
|
||||||
private readonly IGivingCategoryService _categories;
|
private readonly IGivingCategoryService _categories;
|
||||||
|
private readonly IMemberService _members;
|
||||||
private readonly IHubContext<OfferingEntryHub> _hub;
|
private readonly IHubContext<OfferingEntryHub> _hub;
|
||||||
|
|
||||||
public OfferingEntryController(
|
public OfferingEntryController(
|
||||||
IOfferingSessionService sessions,
|
IOfferingSessionService sessions,
|
||||||
IGivingCategoryService categories,
|
IGivingCategoryService categories,
|
||||||
|
IMemberService members,
|
||||||
IHubContext<OfferingEntryHub> hub)
|
IHubContext<OfferingEntryHub> hub)
|
||||||
{
|
{
|
||||||
_sessions = sessions;
|
_sessions = sessions;
|
||||||
_categories = categories;
|
_categories = categories;
|
||||||
|
_members = members;
|
||||||
_hub = hub;
|
_hub = hub;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +51,31 @@ public class OfferingEntryController : ControllerBase
|
|||||||
public async Task<IActionResult> SearchMembers([FromQuery] string? search, [FromQuery] int take = 10)
|
public async Task<IActionResult> SearchMembers([FromQuery] string? search, [FromQuery] int take = 10)
|
||||||
=> Ok(await _sessions.SearchMembersForEntryAsync(search, Math.Clamp(take, 1, 25)));
|
=> 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
|
// Append one offering line to the date's session (find-or-create), then
|
||||||
// broadcast it to everyone viewing that date.
|
// broadcast it to everyone viewing that date.
|
||||||
[HttpPost("lines")]
|
[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
|
date: string; // yyyy-MM-dd
|
||||||
line: OfferingGivingLineRequest;
|
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. */
|
/** Returned from append + broadcast over the OfferingEntryHub. */
|
||||||
export interface OfferingEntryLineAddedDto {
|
export interface OfferingEntryLineAddedDto {
|
||||||
sessionId: number;
|
sessionId: number;
|
||||||
|
|||||||
+43
-2
@@ -40,8 +40,12 @@
|
|||||||
(click)="clearAnonymous()">改回填寫 · Clear</button>
|
(click)="clearAnonymous()">改回填寫 · Clear</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button *ngIf="!entry.isAnonymous" kendoButton fillMode="outline" size="large"
|
<div *ngIf="!entry.isAnonymous" class="oe__giver-actions">
|
||||||
class="oe__anon-btn" (click)="markAnonymous()">設為匿名 · Anonymous</button>
|
<button kendoButton fillMode="outline" size="large"
|
||||||
|
(click)="openQuickAdd()">+ 快速新增 · Quick add</button>
|
||||||
|
<button kendoButton fillMode="outline" size="large"
|
||||||
|
(click)="markAnonymous()">設為匿名 · Anonymous</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oe__field">
|
<div class="oe__field">
|
||||||
@@ -93,4 +97,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="toast" class="oe__toast">{{ toast }}</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>
|
</div>
|
||||||
|
|||||||
+12
-2
@@ -170,9 +170,19 @@
|
|||||||
background: #e0e7ff;
|
background: #e0e7ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oe__anon-btn {
|
.oe__giver-actions {
|
||||||
margin-top: 0.1rem;
|
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 ──────────────────────────────────────────────────── */
|
/* ── Sticky submit bar ──────────────────────────────────────────────────── */
|
||||||
|
|||||||
+75
-2
@@ -5,9 +5,12 @@ import { Subject, takeUntil } from 'rxjs';
|
|||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
import { OfferingEntryApiService } from '../../services/offering-entry-api.service';
|
import { OfferingEntryApiService } from '../../services/offering-entry-api.service';
|
||||||
import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.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';
|
import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||||
|
|
||||||
interface MemberOption { id: number; displayName: string; }
|
interface MemberOption { id: number; displayName: string; }
|
||||||
@@ -21,7 +24,7 @@ interface MemberOption { id: number; displayName: string; }
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-offering-entry-mobile-page',
|
selector: 'app-offering-entry-mobile-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DropDownsModule],
|
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DropDownsModule, DialogsModule],
|
||||||
templateUrl: './offering-entry-mobile-page.component.html',
|
templateUrl: './offering-entry-mobile-page.component.html',
|
||||||
styleUrls: ['./offering-entry-mobile-page.component.scss'],
|
styleUrls: ['./offering-entry-mobile-page.component.scss'],
|
||||||
})
|
})
|
||||||
@@ -48,6 +51,11 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
|||||||
toast: string | null = null;
|
toast: string | null = null;
|
||||||
connected = false;
|
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 toastTimer?: ReturnType<typeof setTimeout>;
|
||||||
private readonly destroy$ = new Subject<void>();
|
private readonly destroy$ = new Subject<void>();
|
||||||
|
|
||||||
@@ -144,6 +152,66 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
|||||||
this.entry.isAnonymous = false;
|
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 {
|
submit(): void {
|
||||||
if (!this.canSubmit) {
|
if (!this.canSubmit) {
|
||||||
return;
|
return;
|
||||||
@@ -185,8 +253,13 @@ export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private resetForm(): void {
|
private resetForm(): void {
|
||||||
const defaultCategory = this.categories[0]?.id ?? 0;
|
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 = this.blankEntry();
|
||||||
this.entry.givingCategoryId = defaultCategory;
|
this.entry.givingCategoryId = defaultCategory;
|
||||||
|
this.entry.paymentMethod = keepMethod;
|
||||||
this.selectedMemberId = null;
|
this.selectedMemberId = null;
|
||||||
this.selectedMemberName = null;
|
this.selectedMemberName = null;
|
||||||
this.memberResults = [];
|
this.memberResults = [];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ApiConfigService } from '../../../core/services/api-config.service';
|
|||||||
import {
|
import {
|
||||||
OfferingEntryBootstrapDto, OfferingEntryLineAddedDto,
|
OfferingEntryBootstrapDto, OfferingEntryLineAddedDto,
|
||||||
AppendOfferingLineRequest, OfferingGivingLineRequest, MemberTypeaheadDto,
|
AppendOfferingLineRequest, OfferingGivingLineRequest, MemberTypeaheadDto,
|
||||||
|
QuickAddMemberRequest,
|
||||||
} from '../models/giving.model';
|
} from '../models/giving.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,4 +40,9 @@ export class OfferingEntryApiService {
|
|||||||
const body: AppendOfferingLineRequest = { date, line };
|
const body: AppendOfferingLineRequest = { date, line };
|
||||||
return this.http.post<OfferingEntryLineAddedDto>(`${this.endpoint}/lines`, body);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user