WIP
This commit is contained in:
@@ -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;
|
||||
|
||||
+43
-2
@@ -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>
|
||||
|
||||
+12
-2
@@ -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 ──────────────────────────────────────────────────── */
|
||||
|
||||
+75
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user