From 8061a60fe5131ef8d355e1bb916539999c1a4398 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Sat, 20 Jun 2026 20:42:06 -0700 Subject: [PATCH] add quick add entry. --- .../Controllers/OfferingEntryController.cs | 59 +++++ .../DTOs/Giving/AppendOfferingLineRequest.cs | 10 + .../DTOs/Giving/MemberTypeaheadDto.cs | 12 + .../DTOs/Giving/OfferingEntryBootstrapDto.cs | 10 + .../DTOs/Giving/OfferingEntryLineAddedDto.cs | 14 ++ .../DTOs/Giving/OfferingEntrySummaryDto.cs | 14 ++ API/ROLAC.API/Hubs/OfferingEntryHub.cs | 21 ++ API/ROLAC.API/Program.cs | 1 + .../Services/IOfferingSessionService.cs | 5 + .../Services/OfferingSessionService.cs | 141 +++++++++++ APP/src/app/app.routes.ts | 4 + .../features/giving/models/giving.model.ts | 38 +++ .../offering-entry-mobile-page.component.html | 96 ++++++++ .../offering-entry-mobile-page.component.scss | 221 ++++++++++++++++++ .../offering-entry-mobile-page.component.ts | 219 +++++++++++++++++ .../offering-session-page.component.ts | 81 ++++++- .../services/offering-entry-api.service.ts | 42 ++++ .../offering-entry-signalr.service.ts | 67 ++++++ 18 files changed, 1050 insertions(+), 5 deletions(-) create mode 100644 API/ROLAC.API/Controllers/OfferingEntryController.cs create mode 100644 API/ROLAC.API/DTOs/Giving/AppendOfferingLineRequest.cs create mode 100644 API/ROLAC.API/DTOs/Giving/MemberTypeaheadDto.cs create mode 100644 API/ROLAC.API/DTOs/Giving/OfferingEntryBootstrapDto.cs create mode 100644 API/ROLAC.API/DTOs/Giving/OfferingEntryLineAddedDto.cs create mode 100644 API/ROLAC.API/DTOs/Giving/OfferingEntrySummaryDto.cs create mode 100644 API/ROLAC.API/Hubs/OfferingEntryHub.cs create mode 100644 APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html create mode 100644 APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss create mode 100644 APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts create mode 100644 APP/src/app/features/giving/services/offering-entry-api.service.ts create mode 100644 APP/src/app/features/giving/services/offering-entry-signalr.service.ts diff --git a/API/ROLAC.API/Controllers/OfferingEntryController.cs b/API/ROLAC.API/Controllers/OfferingEntryController.cs new file mode 100644 index 0000000..b27790f --- /dev/null +++ b/API/ROLAC.API/Controllers/OfferingEntryController.cs @@ -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; + +/// +/// 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). +/// +[ApiController] +[Route("api/offering-entry")] +[AllowAnonymous] +public class OfferingEntryController : ControllerBase +{ + private readonly IOfferingSessionService _sessions; + private readonly IGivingCategoryService _categories; + private readonly IHubContext _hub; + + public OfferingEntryController( + IOfferingSessionService sessions, + IGivingCategoryService categories, + IHubContext 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 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 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 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); + } +} diff --git a/API/ROLAC.API/DTOs/Giving/AppendOfferingLineRequest.cs b/API/ROLAC.API/DTOs/Giving/AppendOfferingLineRequest.cs new file mode 100644 index 0000000..2075ac2 --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/AppendOfferingLineRequest.cs @@ -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(); +} diff --git a/API/ROLAC.API/DTOs/Giving/MemberTypeaheadDto.cs b/API/ROLAC.API/DTOs/Giving/MemberTypeaheadDto.cs new file mode 100644 index 0000000..bd2d0e5 --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/MemberTypeaheadDto.cs @@ -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; } = ""; +} diff --git a/API/ROLAC.API/DTOs/Giving/OfferingEntryBootstrapDto.cs b/API/ROLAC.API/DTOs/Giving/OfferingEntryBootstrapDto.cs new file mode 100644 index 0000000..3254958 --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/OfferingEntryBootstrapDto.cs @@ -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 Categories { get; set; } = []; + public OfferingEntrySummaryDto Summary { get; set; } = new(); +} diff --git a/API/ROLAC.API/DTOs/Giving/OfferingEntryLineAddedDto.cs b/API/ROLAC.API/DTOs/Giving/OfferingEntryLineAddedDto.cs new file mode 100644 index 0000000..8f18796 --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/OfferingEntryLineAddedDto.cs @@ -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(); +} diff --git a/API/ROLAC.API/DTOs/Giving/OfferingEntrySummaryDto.cs b/API/ROLAC.API/DTOs/Giving/OfferingEntrySummaryDto.cs new file mode 100644 index 0000000..0ee2e88 --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/OfferingEntrySummaryDto.cs @@ -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 Lines { get; set; } = []; +} diff --git a/API/ROLAC.API/Hubs/OfferingEntryHub.cs b/API/ROLAC.API/Hubs/OfferingEntryHub.cs new file mode 100644 index 0000000..5ab5a97 --- /dev/null +++ b/API/ROLAC.API/Hubs/OfferingEntryHub.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.SignalR; + +namespace ROLAC.API.Hubs; + +/// +/// 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. +/// +public class OfferingEntryHub : Hub +{ + public Task JoinDate(string date) + => Groups.AddToGroupAsync(Context.ConnectionId, date); + + public Task LeaveDate(string date) + => Groups.RemoveFromGroupAsync(Context.ConnectionId, date); +} diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 1dd20b9..466abf1 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -212,6 +212,7 @@ app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapHub("/hubs/attendance"); +app.MapHub("/hubs/offering-entry"); app.MapHealthChecks("/health"); app.Run(); diff --git a/API/ROLAC.API/Services/IOfferingSessionService.cs b/API/ROLAC.API/Services/IOfferingSessionService.cs index 45ce0f8..768dffc 100644 --- a/API/ROLAC.API/Services/IOfferingSessionService.cs +++ b/API/ROLAC.API/Services/IOfferingSessionService.cs @@ -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 GetEntrySummaryAsync(DateOnly date); + Task AppendLineAsync(DateOnly date, OfferingGivingLineRequest line); + Task> 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); diff --git a/API/ROLAC.API/Services/OfferingSessionService.cs b/API/ROLAC.API/Services/OfferingSessionService.cs index 8c885c9..e174b56 100644 --- a/API/ROLAC.API/Services/OfferingSessionService.cs +++ b/API/ROLAC.API/Services/OfferingSessionService.cs @@ -163,6 +163,147 @@ public class OfferingSessionService : IOfferingSessionService await _db.SaveChangesAsync(); } + // ── Mobile offering entry (anonymous, one line at a time) ──────────────── + + public async Task 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 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> 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> BuildLineDtosAsync(List 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) diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index 860eb0d..7b95f8a 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -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', diff --git a/APP/src/app/features/giving/models/giving.model.ts b/APP/src/app/features/giving/models/giving.model.ts index d095907..273d0a9 100644 --- a/APP/src/app/features/giving/models/giving.model.ts +++ b/APP/src/app/features/giving/models/giving.model.ts @@ -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; +} diff --git a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html new file mode 100644 index 0000000..090ab85 --- /dev/null +++ b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.html @@ -0,0 +1,96 @@ +
+
+
+ River of Life · Offering +

主日奉獻錄入 Sunday Offering Entry

+
{{ todayDate | date:'EEE, MMM d, y' }}
+
+ + {{ connected ? '即時同步中 · Live' : '連線中… · Connecting' }} +
+
+ + +
+
+ {{ lineCount }} + 今日筆數 · Lines +
+
+
+ {{ systemTotal | currency }} + 今日總額 · Total +
+
+ + +
+
+ + + +
+ 匿名 · Anonymous + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+ +
+ +
{{ toast }}
+
diff --git a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss new file mode 100644 index 0000000..b31f245 --- /dev/null +++ b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.scss @@ -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); } +} diff --git a/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts new file mode 100644 index 0000000..4208696 --- /dev/null +++ b/APP/src/app/features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component.ts @@ -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; + private readonly destroy$ = new Subject(); + + 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}`; + } +} diff --git a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts index d3f57bb..82266a4 100644 --- a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts +++ b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts @@ -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(); + 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(); diff --git a/APP/src/app/features/giving/services/offering-entry-api.service.ts b/APP/src/app/features/giving/services/offering-entry-api.service.ts new file mode 100644 index 0000000..2fe12dd --- /dev/null +++ b/APP/src/app/features/giving/services/offering-entry-api.service.ts @@ -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 { + const params = new HttpParams().set('date', date); + return this.http.get(`${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 { + const params = new HttpParams().set('search', search).set('take', take); + return this.http.get(`${this.endpoint}/members`, { params }); + } + + appendLine(date: string, line: OfferingGivingLineRequest): Observable { + const body: AppendOfferingLineRequest = { date, line }; + return this.http.post(`${this.endpoint}/lines`, body); + } +} diff --git a/APP/src/app/features/giving/services/offering-entry-signalr.service.ts b/APP/src/app/features/giving/services/offering-entry-signalr.service.ts new file mode 100644 index 0000000..a143b82 --- /dev/null +++ b/APP/src/app/features/giving/services/offering-entry-signalr.service.ts @@ -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(); + + /** Emits each line appended for a joined date (from this or any other client). */ + get lineAdded$(): Observable { + return this.lineAdded$$.asObservable(); + } + + async start(): Promise { + 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 { + 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 { + if (this.connection?.state !== HubConnectionState.Connected) { + return; + } + await this.connection.invoke('JoinDate', date); + } + + async leaveDate(date: string): Promise { + if (this.connection?.state !== HubConnectionState.Connected) { + return; + } + await this.connection.invoke('LeaveDate', date); + } +}