add quick add entry.

This commit is contained in:
Chris Chen
2026-06-20 20:42:06 -07:00
parent 87425b3276
commit 8061a60fe5
18 changed files with 1050 additions and 5 deletions
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Hubs;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
/// <summary>
/// Anonymous endpoints powering the mobile Sunday offering-entry page. The page
/// has no login yet, so it cannot reach the auth-gated members/categories/
/// offering-sessions APIs — these expose just what it needs (active categories,
/// a name-only member typeahead, and append-one-line).
/// </summary>
[ApiController]
[Route("api/offering-entry")]
[AllowAnonymous]
public class OfferingEntryController : ControllerBase
{
private readonly IOfferingSessionService _sessions;
private readonly IGivingCategoryService _categories;
private readonly IHubContext<OfferingEntryHub> _hub;
public OfferingEntryController(
IOfferingSessionService sessions,
IGivingCategoryService categories,
IHubContext<OfferingEntryHub> hub)
{
_sessions = sessions;
_categories = categories;
_hub = hub;
}
// Seed the page in one round-trip: active categories + today's session state.
[HttpGet("bootstrap")]
public async Task<IActionResult> Bootstrap([FromQuery] DateOnly date)
=> Ok(new OfferingEntryBootstrapDto
{
SessionDate = date.ToString("yyyy-MM-dd"),
Categories = await _categories.GetAllAsync(false),
Summary = await _sessions.GetEntrySummaryAsync(date),
});
// Name-only member suggestions for the giver typeahead.
[HttpGet("members")]
public async Task<IActionResult> SearchMembers([FromQuery] string? search, [FromQuery] int take = 10)
=> Ok(await _sessions.SearchMembersForEntryAsync(search, Math.Clamp(take, 1, 25)));
// Append one offering line to the date's session (find-or-create), then
// broadcast it to everyone viewing that date.
[HttpPost("lines")]
public async Task<IActionResult> AppendLine([FromBody] AppendOfferingLineRequest request)
{
var result = await _sessions.AppendLineAsync(request.Date, request.Line);
await _hub.Clients.Group(result.SessionDate).SendAsync("LineAdded", result);
return Ok(result);
}
}
@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
// Body of POST /api/offering-entry/lines — one offering line plus the date of the
// session it belongs to (find-or-create that day's session, append the line).
public class AppendOfferingLineRequest
{
[Required] public DateOnly Date { get; set; }
[Required] public OfferingGivingLineRequest Line { get; set; } = new();
}
@@ -0,0 +1,12 @@
namespace ROLAC.API.DTOs.Giving;
// Minimal member fields exposed to the anonymous mobile offering-entry page —
// just enough for the giver typeahead to render a display name (matches the
// Angular memberDisplayName helper: NickName ?? FirstName_en, plus LastName_en).
public class MemberTypeaheadDto
{
public int Id { get; set; }
public string? NickName { get; set; }
public string FirstName_en { get; set; } = "";
public string LastName_en { get; set; } = "";
}
@@ -0,0 +1,10 @@
namespace ROLAC.API.DTOs.Giving;
// One-shot payload that seeds the mobile offering-entry page: the active giving
// categories for the Type dropdown and the current state of today's session.
public class OfferingEntryBootstrapDto
{
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
public List<GivingCategoryDto> Categories { get; set; } = [];
public OfferingEntrySummaryDto Summary { get; set; } = new();
}
@@ -0,0 +1,14 @@
namespace ROLAC.API.DTOs.Giving;
// Returned from POST /api/offering-entry/lines and broadcast over the
// OfferingEntryHub: the line just added plus the session's new running totals,
// so every connected client (other phones + the desktop page) can update live.
public class OfferingEntryLineAddedDto
{
public int SessionId { get; set; }
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
public string Status { get; set; } = "";
public decimal SystemTotal { get; set; }
public int LineCount { get; set; }
public OfferingGivingLineDto Line { get; set; } = new();
}
@@ -0,0 +1,14 @@
namespace ROLAC.API.DTOs.Giving;
// A day's offering session as the mobile page sees it: the running total/line
// count plus the lines already recorded. SessionId is null when no session
// exists for the date yet (nothing entered today).
public class OfferingEntrySummaryDto
{
public int? SessionId { get; set; }
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
public string? Status { get; set; } // null when no session yet
public decimal SystemTotal { get; set; }
public int LineCount { get; set; }
public List<OfferingGivingLineDto> Lines { get; set; } = [];
}
+21
View File
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.SignalR;
namespace ROLAC.API.Hubs;
/// <summary>
/// Real-time hub backing the mobile Sunday offering-entry page. Anonymous
/// (no [Authorize]) so volunteers can enter givings on their phones without
/// logging in. Clients join a group named after the session date (yyyy-MM-dd);
/// when a line is appended, the controller broadcasts "LineAdded" to that
/// group so every phone and the desktop Sunday Offering Entry page updating
/// the same date see the new line instantly. The hub itself holds no business
/// logic — broadcasting is done from the controller via IHubContext.
/// </summary>
public class OfferingEntryHub : Hub
{
public Task JoinDate(string date)
=> Groups.AddToGroupAsync(Context.ConnectionId, date);
public Task LeaveDate(string date)
=> Groups.RemoveFromGroupAsync(Context.ConnectionId, date);
}
+1
View File
@@ -212,6 +212,7 @@ app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<ROLAC.API.Hubs.AttendanceHub>("/hubs/attendance");
app.MapHub<ROLAC.API.Hubs.OfferingEntryHub>("/hubs/offering-entry");
app.MapHealthChecks("/health");
app.Run();
@@ -13,6 +13,11 @@ public interface IOfferingSessionService
Task ReopenAsync(int id);
Task ReplaceAsync(int id, CreateOfferingSessionRequest request);
// ── Mobile offering entry (anonymous, one line at a time) ────────────────
Task<OfferingEntrySummaryDto> GetEntrySummaryAsync(DateOnly date);
Task<OfferingEntryLineAddedDto> AppendLineAsync(DateOnly date, OfferingGivingLineRequest line);
Task<List<MemberTypeaheadDto>> SearchMembersForEntryAsync(string? search, int take);
Task SaveProofAsync(int id, Stream content, string fileName);
Task<(Stream stream, string contentType)?> OpenProofAsync(int id);
Task DeleteProofAsync(int id);
@@ -163,6 +163,147 @@ public class OfferingSessionService : IOfferingSessionService
await _db.SaveChangesAsync();
}
// ── Mobile offering entry (anonymous, one line at a time) ────────────────
public async Task<OfferingEntrySummaryDto> GetEntrySummaryAsync(DateOnly date)
{
var session = await _db.OfferingSessions.AsNoTracking()
.FirstOrDefaultAsync(s => s.SessionDate == date);
if (session is null)
return new OfferingEntrySummaryDto { SessionDate = date.ToString("yyyy-MM-dd") };
var lines = await _db.Givings.AsNoTracking()
.Where(g => g.OfferingSessionId == session.Id).ToListAsync();
return new OfferingEntrySummaryDto
{
SessionId = session.Id,
SessionDate = session.SessionDate.ToString("yyyy-MM-dd"),
Status = session.Status,
SystemTotal = session.SystemTotal,
LineCount = lines.Count,
Lines = await BuildLineDtosAsync(lines),
};
}
public async Task<OfferingEntryLineAddedDto> AppendLineAsync(DateOnly date, OfferingGivingLineRequest line)
{
var session = await _db.OfferingSessions
.Include(s => s.Givings)
.FirstOrDefaultAsync(s => s.SessionDate == date);
Giving giving;
if (session is null)
{
session = new OfferingSession { SessionDate = date, Status = "Draft" };
_db.OfferingSessions.Add(session);
giving = MapLine(line, date);
session.Givings.Add(giving);
RecalcTotals(session);
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateException)
{
// Lost the create race — another phone inserted today's session first.
// Start clean and append to the now-existing session instead.
_db.ChangeTracker.Clear();
session = await _db.OfferingSessions.Include(s => s.Givings)
.FirstAsync(s => s.SessionDate == date);
ReopenIfLocked(session);
giving = MapLine(line, date);
session.Givings.Add(giving);
RecalcTotals(session);
await _db.SaveChangesAsync();
}
}
else
{
ReopenIfLocked(session); // auto-reopen a Submitted/Reconciled session (decision #3)
giving = MapLine(line, date);
session.Givings.Add(giving);
RecalcTotals(session);
await _db.SaveChangesAsync();
}
var lineDto = (await BuildLineDtosAsync([giving])).Single();
return new OfferingEntryLineAddedDto
{
SessionId = session.Id,
SessionDate = session.SessionDate.ToString("yyyy-MM-dd"),
Status = session.Status,
SystemTotal = session.SystemTotal,
LineCount = session.Givings.Count,
Line = lineDto,
};
}
public async Task<List<MemberTypeaheadDto>> SearchMembersForEntryAsync(string? search, int take)
{
if (string.IsNullOrWhiteSpace(search))
return [];
// ILike is a case-insensitive LIKE (PostgreSQL) — so "chen" matches "Chen".
var pattern = $"%{search.Trim()}%";
return await _db.Members.AsNoTracking()
.Where(m => EF.Functions.ILike(m.FirstName_en, pattern)
|| EF.Functions.ILike(m.LastName_en, pattern)
|| (m.NickName != null && EF.Functions.ILike(m.NickName, pattern))
|| (m.FirstName_zh != null && EF.Functions.ILike(m.FirstName_zh, pattern))
|| (m.LastName_zh != null && EF.Functions.ILike(m.LastName_zh, pattern)))
.OrderBy(m => m.LastName_en).ThenBy(m => m.FirstName_en)
.Take(take)
.Select(m => new MemberTypeaheadDto
{
Id = m.Id, NickName = m.NickName,
FirstName_en = m.FirstName_en, LastName_en = m.LastName_en,
})
.ToListAsync();
}
// Resolve category + member names for a set of lines (shared by entry summary/append).
private async Task<List<OfferingGivingLineDto>> BuildLineDtosAsync(List<Giving> lines)
{
var catNames = await _db.GivingCategories.AsNoTracking()
.ToDictionaryAsync(c => c.Id, c => c.Name_en);
var memberIds = lines.Where(l => l.MemberId != null).Select(l => l.MemberId!.Value).ToHashSet();
var memberNames = await _db.Members.AsNoTracking()
.Where(m => memberIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}");
return lines.Select(l => new OfferingGivingLineDto
{
Id = l.Id, MemberId = l.MemberId,
MemberName = l.MemberId != null && memberNames.TryGetValue(l.MemberId.Value, out var n) ? n : null,
GivingCategoryId = l.GivingCategoryId,
CategoryName = catNames.TryGetValue(l.GivingCategoryId, out var cn) ? cn : "",
Amount = l.Amount, PaymentMethod = l.PaymentMethod,
CheckNumber = l.CheckNumber,
ZelleReferenceCode = l.ZelleReferenceCode,
PayPalTransactionId = l.PayPalTransactionId,
IsAnonymous = l.IsAnonymous, Notes = l.Notes,
}).ToList();
}
private static void RecalcTotals(OfferingSession session)
{
session.SystemTotal = session.Givings.Sum(g => g.Amount);
session.Difference = (session.CashTotal + session.CheckTotal) - session.SystemTotal;
}
// Revert a locked (Submitted/Reconciled) session to Draft so entry can continue.
private static void ReopenIfLocked(OfferingSession session)
{
if (session.Status is "Submitted" or "Reconciled")
{
session.Status = "Draft";
session.SubmittedAt = null; session.SubmittedBy = null;
session.ReconciledAt = null; session.ReconciledBy = null;
}
}
// ── Paper-proof PDF (one merged file per session) ────────────────────────
public async Task SaveProofAsync(int id, Stream content, string fileName)
+4
View File
@@ -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;
}
@@ -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>
@@ -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); }
}
@@ -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}`;
}
}
@@ -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);
}
}