add quick add entry.
This commit is contained in:
@@ -18,6 +18,7 @@ import { DisbursementPageComponent } from './features/disbursement/pages/disburs
|
||||
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
|
||||
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
|
||||
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
|
||||
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
// Public routes
|
||||
@@ -26,6 +27,9 @@ export const routes: Routes = [
|
||||
// Public Sunday meal attendance counter — no login required (volunteers on phones).
|
||||
{ path: 'attendance', component: AttendanceCounterPageComponent },
|
||||
|
||||
// Public mobile Sunday offering entry — no login required (co-workers on phones).
|
||||
{ path: 'offering-entry', component: OfferingEntryMobilePageComponent },
|
||||
|
||||
// Keep the startup surface intentionally small: login + guarded mock dashboard.
|
||||
{
|
||||
path: 'user-portal',
|
||||
|
||||
@@ -121,3 +121,41 @@ export interface OfferingBufferLine extends OfferingGivingLineRequest {
|
||||
memberName: string | null; // for display only
|
||||
categoryName: string; // for display only
|
||||
}
|
||||
|
||||
// ── Mobile offering entry (anonymous, one line at a time) ─────────
|
||||
/** Minimal member fields the anonymous giver typeahead needs. */
|
||||
export interface MemberTypeaheadDto {
|
||||
id: number;
|
||||
nickName: string | null;
|
||||
firstName_en: string;
|
||||
lastName_en: string;
|
||||
}
|
||||
/** A day's session as the mobile page sees it. */
|
||||
export interface OfferingEntrySummaryDto {
|
||||
sessionId: number | null; // null when no session exists for the date yet
|
||||
sessionDate: string; // yyyy-MM-dd
|
||||
status: SessionStatus | null;
|
||||
systemTotal: number;
|
||||
lineCount: number;
|
||||
lines: OfferingGivingLineDto[];
|
||||
}
|
||||
/** One-shot payload that seeds the mobile page. */
|
||||
export interface OfferingEntryBootstrapDto {
|
||||
sessionDate: string; // yyyy-MM-dd
|
||||
categories: GivingCategoryDto[];
|
||||
summary: OfferingEntrySummaryDto;
|
||||
}
|
||||
/** Body of POST /api/offering-entry/lines. */
|
||||
export interface AppendOfferingLineRequest {
|
||||
date: string; // yyyy-MM-dd
|
||||
line: OfferingGivingLineRequest;
|
||||
}
|
||||
/** Returned from append + broadcast over the OfferingEntryHub. */
|
||||
export interface OfferingEntryLineAddedDto {
|
||||
sessionId: number;
|
||||
sessionDate: string; // yyyy-MM-dd
|
||||
status: SessionStatus;
|
||||
systemTotal: number;
|
||||
lineCount: number;
|
||||
line: OfferingGivingLineDto;
|
||||
}
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
<div class="oe">
|
||||
<div class="oe__inner">
|
||||
<header class="oe__head">
|
||||
<span class="oe__eyebrow">River of Life · Offering</span>
|
||||
<h1 class="oe__title">主日奉獻錄入 <span>Sunday Offering Entry</span></h1>
|
||||
<div class="oe__date">{{ todayDate | date:'EEE, MMM d, y' }}</div>
|
||||
<div class="oe__status" [class.is-on]="connected">
|
||||
<span class="oe__dot"></span>
|
||||
{{ connected ? '即時同步中 · Live' : '連線中… · Connecting' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Today's running tally (live across phones) -->
|
||||
<div class="oe__tally">
|
||||
<div class="oe__tally-item">
|
||||
<span class="oe__tally-num">{{ lineCount }}</span>
|
||||
<span class="oe__tally-label">今日筆數 · Lines</span>
|
||||
</div>
|
||||
<div class="oe__tally-divider"></div>
|
||||
<div class="oe__tally-item">
|
||||
<span class="oe__tally-num">{{ systemTotal | currency }}</span>
|
||||
<span class="oe__tally-label">今日總額 · Total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Entry form -->
|
||||
<section class="oe__card">
|
||||
<div class="oe__field">
|
||||
<label class="oe__label">奉獻人 · Giver</label>
|
||||
<kendo-combobox *ngIf="!entry.isAnonymous"
|
||||
class="oe__control"
|
||||
[data]="memberResults" textField="displayName" valueField="id" [valuePrimitive]="true"
|
||||
[filterable]="true" (filterChange)="onMemberFilter($event)"
|
||||
[(ngModel)]="selectedMemberId" (valueChange)="onMemberSelected($event)"
|
||||
size="large" placeholder="輸入姓名搜尋 · Search by name"></kendo-combobox>
|
||||
|
||||
<div *ngIf="entry.isAnonymous" class="oe__anon">
|
||||
<span class="oe__anon-chip">匿名 · Anonymous</span>
|
||||
<button kendoButton fillMode="flat" themeColor="primary" size="large"
|
||||
(click)="clearAnonymous()">改回填寫 · Clear</button>
|
||||
</div>
|
||||
|
||||
<button *ngIf="!entry.isAnonymous" kendoButton fillMode="outline" size="large"
|
||||
class="oe__anon-btn" (click)="markAnonymous()">設為匿名 · Anonymous</button>
|
||||
</div>
|
||||
|
||||
<div class="oe__field">
|
||||
<label class="oe__label">類別 · Type</label>
|
||||
<kendo-dropdownlist class="oe__control"
|
||||
[data]="categories" textField="label" valueField="id" [valuePrimitive]="true"
|
||||
[(ngModel)]="entry.givingCategoryId" size="large"></kendo-dropdownlist>
|
||||
</div>
|
||||
|
||||
<div class="oe__field">
|
||||
<label class="oe__label">付款方式 · Method</label>
|
||||
<kendo-dropdownlist class="oe__control"
|
||||
[data]="paymentMethods" textField="label" valueField="value" [valuePrimitive]="true"
|
||||
[(ngModel)]="entry.paymentMethod" size="large"></kendo-dropdownlist>
|
||||
</div>
|
||||
|
||||
<div class="oe__field" *ngIf="entry.paymentMethod === 'Check'">
|
||||
<label class="oe__label">支票號碼 · Check #</label>
|
||||
<kendo-textbox class="oe__control" [(ngModel)]="entry.checkNumber" size="large"></kendo-textbox>
|
||||
</div>
|
||||
<div class="oe__field" *ngIf="entry.paymentMethod === 'Zelle'">
|
||||
<label class="oe__label">Zelle 參考碼 · Reference</label>
|
||||
<kendo-textbox class="oe__control" [(ngModel)]="entry.zelleReferenceCode" size="large"></kendo-textbox>
|
||||
</div>
|
||||
<div class="oe__field" *ngIf="entry.paymentMethod === 'PayPal'">
|
||||
<label class="oe__label">PayPal 交易編號 · Txn ID</label>
|
||||
<kendo-textbox class="oe__control" [(ngModel)]="entry.payPalTransactionId" size="large"></kendo-textbox>
|
||||
</div>
|
||||
|
||||
<div class="oe__field">
|
||||
<label class="oe__label">金額 · Amount</label>
|
||||
<kendo-numerictextbox class="oe__control"
|
||||
[(ngModel)]="entry.amount" [min]="0" [format]="'c2'" size="large"></kendo-numerictextbox>
|
||||
</div>
|
||||
|
||||
<div class="oe__field">
|
||||
<label class="oe__label">備註 · Notes</label>
|
||||
<kendo-textbox class="oe__control" [(ngModel)]="entry.notes" size="large"></kendo-textbox>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Sticky submit bar -->
|
||||
<div class="oe__submit">
|
||||
<button kendoButton themeColor="primary" size="large" class="oe__submit-btn"
|
||||
[disabled]="!canSubmit" (click)="submit()">
|
||||
{{ submitting ? '送出中… · Submitting' : '送出 · Submit' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="toast" class="oe__toast">{{ toast }}</div>
|
||||
</div>
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.oe {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #eef2f7;
|
||||
color: #0f172a;
|
||||
// Leave room for the sticky submit bar + the phone's home-indicator inset.
|
||||
padding-bottom: calc(5.5rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.oe__inner {
|
||||
width: 100%;
|
||||
max-width: 30rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
.oe__head {
|
||||
margin: 0 -1rem;
|
||||
padding: calc(1.25rem + env(safe-area-inset-top)) 1.25rem 1.25rem;
|
||||
text-align: center;
|
||||
color: #f8fafc;
|
||||
background: linear-gradient(135deg, #2563eb, #4f46e5);
|
||||
border-radius: 0 0 1.5rem 1.5rem;
|
||||
box-shadow: 0 10px 24px -14px rgba(37, 99, 235, 0.7);
|
||||
}
|
||||
|
||||
.oe__eyebrow {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(226, 232, 240, 0.85);
|
||||
}
|
||||
|
||||
.oe__title {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: clamp(1.4rem, 6vw, 1.75rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.oe__date {
|
||||
margin-top: 0.6rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.oe__status {
|
||||
margin-top: 0.45rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
|
||||
&.is-on { color: #bbf7d0; }
|
||||
}
|
||||
|
||||
.oe__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
|
||||
.is-on & {
|
||||
background: #4ade80;
|
||||
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Today tally ────────────────────────────────────────────────────────── */
|
||||
.oe__tally {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 0.9rem 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 6px 18px -14px rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
.oe__tally-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.oe__tally-num {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
color: #1d4ed8;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.oe__tally-label {
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.oe__tally-divider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ── Form card ──────────────────────────────────────────────────────────── */
|
||||
.oe__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem 1.1rem 1.25rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 1.1rem;
|
||||
box-shadow: 0 6px 18px -14px rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
.oe__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.oe__label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
/* Make every Kendo control fill the row for big, thumb-friendly targets. */
|
||||
.oe__control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oe__anon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.oe__anon-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #4338ca;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
.oe__anon-btn {
|
||||
margin-top: 0.1rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ── Sticky submit bar ──────────────────────────────────────────────────── */
|
||||
.oe__submit {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||
background: rgba(238, 242, 247, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border-top: 1px solid #dbe2ea;
|
||||
}
|
||||
|
||||
.oe__submit-btn {
|
||||
width: 100%;
|
||||
max-width: 30rem;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
min-height: 3.25rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Toast ──────────────────────────────────────────────────────────────── */
|
||||
.oe__toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(5.25rem + env(safe-area-inset-bottom));
|
||||
z-index: 30;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #f0fdf4;
|
||||
background: #16a34a;
|
||||
box-shadow: 0 12px 30px -12px rgba(22, 163, 74, 0.7);
|
||||
animation: oe-toast-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes oe-toast-in {
|
||||
from { opacity: 0; transform: translate(-50%, 0.5rem); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
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 { OfferingEntryApiService } from '../../services/offering-entry-api.service';
|
||||
import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.service';
|
||||
import { GivingCategoryDto, OfferingGivingLineRequest, MemberTypeaheadDto } from '../../models/giving.model';
|
||||
import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||
|
||||
interface MemberOption { id: number; displayName: string; }
|
||||
|
||||
/**
|
||||
* Portrait, phone-friendly page where a volunteer records one Sunday offering
|
||||
* at a time. Fields mirror the desktop "Add Giving" form. Each submit persists
|
||||
* a single line to today's session (find-or-create, server-side) and the form
|
||||
* resets blank for the next entry. No login required.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-offering-entry-mobile-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DropDownsModule],
|
||||
templateUrl: './offering-entry-mobile-page.component.html',
|
||||
styleUrls: ['./offering-entry-mobile-page.component.scss'],
|
||||
})
|
||||
export class OfferingEntryMobilePageComponent implements OnInit, OnDestroy {
|
||||
/** Auto-selected current day (decision: page defaults to today's session). */
|
||||
readonly todayDate = new Date();
|
||||
private readonly today = this.toIso(this.todayDate);
|
||||
|
||||
categories: GivingCategoryDto[] = [];
|
||||
readonly paymentMethods = PAYMENT_METHOD_OPTIONS;
|
||||
|
||||
memberResults: MemberOption[] = [];
|
||||
selectedMemberId: number | null = null;
|
||||
selectedMemberName: string | null = null;
|
||||
|
||||
entry: OfferingGivingLineRequest = this.blankEntry();
|
||||
|
||||
// Live running tally for today — seeded by bootstrap, kept current by SignalR
|
||||
// (so multiple phones agree) and by each successful submit.
|
||||
lineCount = 0;
|
||||
systemTotal = 0;
|
||||
|
||||
submitting = false;
|
||||
toast: string | null = null;
|
||||
connected = false;
|
||||
|
||||
private toastTimer?: ReturnType<typeof setTimeout>;
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private api: OfferingEntryApiService,
|
||||
private signalr: OfferingEntrySignalrService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.api.bootstrap(this.today).subscribe(dto => {
|
||||
this.categories = dto.categories;
|
||||
this.entry.givingCategoryId = dto.categories[0]?.id ?? 0;
|
||||
this.lineCount = dto.summary.lineCount;
|
||||
this.systemTotal = dto.summary.systemTotal;
|
||||
});
|
||||
|
||||
this.signalr.lineAdded$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(evt => {
|
||||
if (evt.sessionDate !== this.today) {
|
||||
return;
|
||||
}
|
||||
this.lineCount = evt.lineCount;
|
||||
this.systemTotal = evt.systemTotal;
|
||||
});
|
||||
|
||||
this.signalr.start()
|
||||
.then(() => {
|
||||
this.connected = true;
|
||||
return this.signalr.joinDate(this.today);
|
||||
})
|
||||
.catch(() => (this.connected = false));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.signalr.leaveDate(this.today)
|
||||
.catch(() => undefined)
|
||||
.then(() => this.signalr.stop());
|
||||
}
|
||||
|
||||
get canSubmit(): boolean {
|
||||
if (this.submitting || this.entry.amount <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
onMemberFilter(term: string): void {
|
||||
if (!term) {
|
||||
this.memberResults = [];
|
||||
return;
|
||||
}
|
||||
this.api.searchMembers(term, 10).subscribe(list =>
|
||||
this.memberResults = list.map(m => ({ id: m.id, displayName: this.giverLabel(m) })));
|
||||
}
|
||||
|
||||
// "NickName LastName (Legal FirstName LastName)" — the legal name in parens so
|
||||
// a nick name is never ambiguous. Falls back to the legal name alone when there
|
||||
// is no nick name (or it's the same as the legal first name).
|
||||
private giverLabel(m: MemberTypeaheadDto): string {
|
||||
const legal = `${m.firstName_en} ${m.lastName_en}`.trim();
|
||||
if (m.nickName && m.nickName !== m.firstName_en) {
|
||||
return `${m.nickName} ${m.lastName_en} (${legal})`;
|
||||
}
|
||||
return legal;
|
||||
}
|
||||
|
||||
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;
|
||||
this.memberResults = [];
|
||||
}
|
||||
|
||||
clearAnonymous(): void {
|
||||
this.entry.isAnonymous = false;
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (!this.canSubmit) {
|
||||
return;
|
||||
}
|
||||
this.submitting = true;
|
||||
this.api.appendLine(this.today, this.normalizedLine()).subscribe({
|
||||
next: res => {
|
||||
this.submitting = false;
|
||||
// Server is the source of truth; update now in case our own broadcast
|
||||
// hasn't echoed back to this client yet.
|
||||
this.lineCount = res.lineCount;
|
||||
this.systemTotal = res.systemTotal;
|
||||
this.showToast('已登打 ✓ Recorded');
|
||||
this.resetForm();
|
||||
},
|
||||
error: (err: { error?: { message?: string } }) => {
|
||||
this.submitting = false;
|
||||
this.showToast(err?.error?.message ?? '登打失敗 Submit failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Send null for fields that don't apply to the chosen method so a stale check
|
||||
// number (etc.) from a since-changed method isn't persisted.
|
||||
private normalizedLine(): OfferingGivingLineRequest {
|
||||
const e = this.entry;
|
||||
return {
|
||||
memberId: e.isAnonymous ? null : e.memberId,
|
||||
givingCategoryId: e.givingCategoryId,
|
||||
amount: e.amount,
|
||||
paymentMethod: e.paymentMethod,
|
||||
checkNumber: e.paymentMethod === 'Check' ? (e.checkNumber || null) : null,
|
||||
zelleReferenceCode: e.paymentMethod === 'Zelle' ? (e.zelleReferenceCode || null) : null,
|
||||
payPalTransactionId: e.paymentMethod === 'PayPal' ? (e.payPalTransactionId || null) : null,
|
||||
isAnonymous: e.isAnonymous,
|
||||
notes: e.notes || null,
|
||||
};
|
||||
}
|
||||
|
||||
private resetForm(): void {
|
||||
const defaultCategory = this.categories[0]?.id ?? 0;
|
||||
this.entry = this.blankEntry();
|
||||
this.entry.givingCategoryId = defaultCategory;
|
||||
this.selectedMemberId = null;
|
||||
this.selectedMemberName = null;
|
||||
this.memberResults = [];
|
||||
}
|
||||
|
||||
private blankEntry(): OfferingGivingLineRequest {
|
||||
return {
|
||||
memberId: null, givingCategoryId: 0, amount: 0, paymentMethod: 'Cash',
|
||||
checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null,
|
||||
isAnonymous: false, notes: null,
|
||||
};
|
||||
}
|
||||
|
||||
private showToast(message: string): void {
|
||||
this.toast = message;
|
||||
if (this.toastTimer) {
|
||||
clearTimeout(this.toastTimer);
|
||||
}
|
||||
this.toastTimer = setTimeout(() => (this.toast = null), 2200);
|
||||
}
|
||||
|
||||
// Format using LOCAL date components — NOT toISOString(), which converts to UTC
|
||||
// and can roll the date forward a day for behind-UTC users.
|
||||
private toIso(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
}
|
||||
+76
-5
@@ -1,7 +1,7 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Observable, from, of, map, switchMap } from 'rxjs';
|
||||
import { Observable, Subject, from, of, map, switchMap, takeUntil } from 'rxjs';
|
||||
import { buildProofPdf } from '../../services/proof-pdf.builder';
|
||||
import { GridModule } from '@progress/kendo-angular-grid';
|
||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||
@@ -10,13 +10,14 @@ import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||
import { OfferingSessionApiService } from '../../services/offering-session-api.service';
|
||||
import { OfferingEntrySignalrService } from '../../services/offering-entry-signalr.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,
|
||||
OfferingSessionListItemDto, OfferingSessionDto,
|
||||
OfferingSessionListItemDto, OfferingSessionDto, OfferingGivingLineDto,
|
||||
} from '../../models/giving.model';
|
||||
import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||
|
||||
@@ -34,9 +35,14 @@ type PageMode = 'landing' | 'workspace' | 'view';
|
||||
templateUrl: './offering-session-page.component.html',
|
||||
styleUrls: ['./offering-session-page.component.scss'],
|
||||
})
|
||||
export class OfferingSessionPageComponent implements OnInit {
|
||||
export class OfferingSessionPageComponent implements OnInit, OnDestroy {
|
||||
mode: PageMode = 'landing';
|
||||
|
||||
// The session date currently joined for live mobile-entry updates (yyyy-MM-dd),
|
||||
// and a teardown signal for the SignalR subscription.
|
||||
private liveDate: string | null = null;
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
sessionDate: Date = new Date();
|
||||
dateConflict = false;
|
||||
categories: GivingCategoryDto[] = [];
|
||||
@@ -72,6 +78,7 @@ export class OfferingSessionPageComponent implements OnInit {
|
||||
private api: OfferingSessionApiService,
|
||||
private categoryApi: GivingCategoryApiService,
|
||||
private memberApi: MemberApiService,
|
||||
private signalr: OfferingEntrySignalrService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -81,6 +88,67 @@ export class OfferingSessionPageComponent implements OnInit {
|
||||
});
|
||||
this.checkDate();
|
||||
this.loadSessions();
|
||||
|
||||
// Live updates: when a volunteer adds a line on the mobile page for the date
|
||||
// we're currently viewing/editing, reflect it here without a manual refresh.
|
||||
this.signalr.lineAdded$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(evt => this.onMobileLineAdded(evt));
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
this.leaveLive();
|
||||
this.signalr.stop();
|
||||
}
|
||||
|
||||
// ── Live mobile-entry sync ─────────────────────────────────────────────────
|
||||
|
||||
private onMobileLineAdded(evt: { sessionDate: string; sessionId: number; line: OfferingGivingLineDto }): void {
|
||||
if (evt.sessionDate !== this.liveDate) {
|
||||
return;
|
||||
}
|
||||
if (this.mode === 'view' && this.viewSession && this.viewSession.id === evt.sessionId) {
|
||||
// Re-fetch so the read-only Lines list (and totals/status) stays authoritative.
|
||||
this.api.getById(this.viewSession.id).subscribe(dto => this.viewSession = dto);
|
||||
} else if (this.mode === 'workspace' && this.editingSessionId === evt.sessionId) {
|
||||
// Append to the open editor's buffer so it includes lines added from phones.
|
||||
this.buffer = [...this.buffer, this.bufferLineFromDto(evt.line)];
|
||||
}
|
||||
}
|
||||
|
||||
private bufferLineFromDto(g: OfferingGivingLineDto): OfferingBufferLine {
|
||||
return {
|
||||
memberId: g.memberId, givingCategoryId: g.givingCategoryId, amount: g.amount,
|
||||
paymentMethod: g.paymentMethod, checkNumber: g.checkNumber,
|
||||
zelleReferenceCode: g.zelleReferenceCode, payPalTransactionId: g.payPalTransactionId,
|
||||
isAnonymous: g.isAnonymous, notes: g.notes,
|
||||
memberName: g.memberName, categoryName: g.categoryName,
|
||||
};
|
||||
}
|
||||
|
||||
/** Subscribe to live updates for one session date, leaving any previous one. */
|
||||
private joinLive(date: string): void {
|
||||
this.signalr.start()
|
||||
.then(() => {
|
||||
if (this.liveDate && this.liveDate !== date) {
|
||||
return this.signalr.leaveDate(this.liveDate).then(() => undefined);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.then(() => {
|
||||
this.liveDate = date;
|
||||
return this.signalr.joinDate(date);
|
||||
})
|
||||
.catch(() => { /* offline is fine — the page still works without live sync */ });
|
||||
}
|
||||
|
||||
private leaveLive(): void {
|
||||
if (this.liveDate) {
|
||||
this.signalr.leaveDate(this.liveDate).catch(() => undefined);
|
||||
this.liveDate = null;
|
||||
}
|
||||
}
|
||||
|
||||
get systemTotal(): number { return this.buffer.reduce((s, l) => s + (l.amount || 0), 0); }
|
||||
@@ -106,6 +174,7 @@ export class OfferingSessionPageComponent implements OnInit {
|
||||
this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
|
||||
this.pendingProofFiles = [];
|
||||
this.resetEntry();
|
||||
this.joinLive(this.toIso(this.sessionDate));
|
||||
this.mode = 'workspace';
|
||||
}
|
||||
|
||||
@@ -125,7 +194,7 @@ export class OfferingSessionPageComponent implements OnInit {
|
||||
/** Open a session read-only (from a Recent Sessions row or a resolved date). */
|
||||
openView(s: OfferingSessionListItemDto): void {
|
||||
this.api.getById(s.id).subscribe({
|
||||
next: dto => { this.viewSession = dto; this.mode = 'view'; },
|
||||
next: dto => { this.viewSession = dto; this.joinLive(dto.sessionDate); this.mode = 'view'; },
|
||||
error: (err: { error?: { message?: string } }) => alert(err?.error?.message ?? 'Load failed.'),
|
||||
});
|
||||
}
|
||||
@@ -161,6 +230,7 @@ export class OfferingSessionPageComponent implements OnInit {
|
||||
|
||||
/** Leave workspace/view and return to the date-first landing screen. */
|
||||
backToLanding(): void {
|
||||
this.leaveLive();
|
||||
this.resetSession();
|
||||
this.mode = 'landing';
|
||||
this.loadSessions();
|
||||
@@ -276,6 +346,7 @@ export class OfferingSessionPageComponent implements OnInit {
|
||||
next: () => {
|
||||
this.submitting = false;
|
||||
alert(isEdit ? 'Offering session updated.' : 'Offering session submitted.');
|
||||
this.leaveLive();
|
||||
this.resetSession();
|
||||
this.mode = 'landing';
|
||||
this.loadSessions();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { bilingual } from '../../../shared/i18n/bilingual';
|
||||
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||
import {
|
||||
OfferingEntryBootstrapDto, OfferingEntryLineAddedDto,
|
||||
AppendOfferingLineRequest, OfferingGivingLineRequest, MemberTypeaheadDto,
|
||||
} from '../models/giving.model';
|
||||
|
||||
/**
|
||||
* Anonymous API for the mobile offering-entry page. Unlike the other giving
|
||||
* services these endpoints require no JWT (the page has no login yet).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OfferingEntryApiService {
|
||||
private readonly endpoint: string;
|
||||
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||
this.endpoint = apiConfig.getApiUrl('offering-entry');
|
||||
}
|
||||
|
||||
/** Categories + today's session state, with the bilingual category label computed client-side. */
|
||||
bootstrap(date: string): Observable<OfferingEntryBootstrapDto> {
|
||||
const params = new HttpParams().set('date', date);
|
||||
return this.http.get<OfferingEntryBootstrapDto>(`${this.endpoint}/bootstrap`, { params }).pipe(
|
||||
map(dto => ({
|
||||
...dto,
|
||||
categories: dto.categories.map(c => ({ ...c, label: bilingual(c.name_en, c.name_zh) })),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
searchMembers(search: string, take = 10): Observable<MemberTypeaheadDto[]> {
|
||||
const params = new HttpParams().set('search', search).set('take', take);
|
||||
return this.http.get<MemberTypeaheadDto[]>(`${this.endpoint}/members`, { params });
|
||||
}
|
||||
|
||||
appendLine(date: string, line: OfferingGivingLineRequest): Observable<OfferingEntryLineAddedDto> {
|
||||
const body: AppendOfferingLineRequest = { date, line };
|
||||
return this.http.post<OfferingEntryLineAddedDto>(`${this.endpoint}/lines`, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import {
|
||||
HubConnection, HubConnectionBuilder, HubConnectionState, LogLevel,
|
||||
} from '@microsoft/signalr';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { OfferingEntryLineAddedDto } from '../models/giving.model';
|
||||
|
||||
/**
|
||||
* Thin wrapper around the OfferingEntryHub connection. Clients join the group
|
||||
* for a session date and receive a "LineAdded" event whenever any phone appends
|
||||
* a line for that date, so every device (and the desktop Sunday Offering Entry
|
||||
* page) updates its Lines list live. Mirrors AttendanceSignalrService.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OfferingEntrySignalrService {
|
||||
// Hub lives at the host root; environment.apiUrl is e.g. http://localhost:42019/api
|
||||
private readonly hubUrl = environment.apiUrl.replace(/\/api\/?$/, '') + '/hubs/offering-entry';
|
||||
|
||||
private connection?: HubConnection;
|
||||
private readonly lineAdded$$ = new Subject<OfferingEntryLineAddedDto>();
|
||||
|
||||
/** Emits each line appended for a joined date (from this or any other client). */
|
||||
get lineAdded$(): Observable<OfferingEntryLineAddedDto> {
|
||||
return this.lineAdded$$.asObservable();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.connection = new HubConnectionBuilder()
|
||||
.withUrl(this.hubUrl)
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
this.connection.on('LineAdded', (dto: OfferingEntryLineAddedDto) => {
|
||||
this.lineAdded$$.next(dto);
|
||||
});
|
||||
|
||||
await this.connection.start();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.connection) {
|
||||
await this.connection.stop();
|
||||
this.connection = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to live updates for one session date (yyyy-MM-dd). */
|
||||
async joinDate(date: string): Promise<void> {
|
||||
if (this.connection?.state !== HubConnectionState.Connected) {
|
||||
return;
|
||||
}
|
||||
await this.connection.invoke('JoinDate', date);
|
||||
}
|
||||
|
||||
async leaveDate(date: string): Promise<void> {
|
||||
if (this.connection?.state !== HubConnectionState.Connected) {
|
||||
return;
|
||||
}
|
||||
await this.connection.invoke('LeaveDate', date);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user