add quick add entry.
This commit is contained in:
@@ -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; } = [];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -212,6 +212,7 @@ app.UseAuthentication();
|
|||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapHub<ROLAC.API.Hubs.AttendanceHub>("/hubs/attendance");
|
app.MapHub<ROLAC.API.Hubs.AttendanceHub>("/hubs/attendance");
|
||||||
|
app.MapHub<ROLAC.API.Hubs.OfferingEntryHub>("/hubs/offering-entry");
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ public interface IOfferingSessionService
|
|||||||
Task ReopenAsync(int id);
|
Task ReopenAsync(int id);
|
||||||
Task ReplaceAsync(int id, CreateOfferingSessionRequest request);
|
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 SaveProofAsync(int id, Stream content, string fileName);
|
||||||
Task<(Stream stream, string contentType)?> OpenProofAsync(int id);
|
Task<(Stream stream, string contentType)?> OpenProofAsync(int id);
|
||||||
Task DeleteProofAsync(int id);
|
Task DeleteProofAsync(int id);
|
||||||
|
|||||||
@@ -163,6 +163,147 @@ public class OfferingSessionService : IOfferingSessionService
|
|||||||
await _db.SaveChangesAsync();
|
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) ────────────────────────
|
// ── Paper-proof PDF (one merged file per session) ────────────────────────
|
||||||
|
|
||||||
public async Task SaveProofAsync(int id, Stream content, string fileName)
|
public async Task SaveProofAsync(int id, Stream content, string fileName)
|
||||||
|
|||||||
@@ -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 { 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 { 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 { 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 = [
|
export const routes: Routes = [
|
||||||
// Public routes
|
// Public routes
|
||||||
@@ -26,6 +27,9 @@ export const routes: Routes = [
|
|||||||
// Public Sunday meal attendance counter — no login required (volunteers on phones).
|
// Public Sunday meal attendance counter — no login required (volunteers on phones).
|
||||||
{ path: 'attendance', component: AttendanceCounterPageComponent },
|
{ 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.
|
// Keep the startup surface intentionally small: login + guarded mock dashboard.
|
||||||
{
|
{
|
||||||
path: 'user-portal',
|
path: 'user-portal',
|
||||||
|
|||||||
@@ -121,3 +121,41 @@ export interface OfferingBufferLine extends OfferingGivingLineRequest {
|
|||||||
memberName: string | null; // for display only
|
memberName: string | null; // for display only
|
||||||
categoryName: string; // 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 { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
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 { buildProofPdf } from '../../services/proof-pdf.builder';
|
||||||
import { GridModule } from '@progress/kendo-angular-grid';
|
import { GridModule } from '@progress/kendo-angular-grid';
|
||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
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 { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
import { OfferingSessionApiService } from '../../services/offering-session-api.service';
|
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 { GivingCategoryApiService } from '../../services/giving-category-api.service';
|
||||||
import { MemberApiService } from '../../../members/services/member-api.service';
|
import { MemberApiService } from '../../../members/services/member-api.service';
|
||||||
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||||
import { MemberQuickAddDialogComponent } from '../../components/member-quick-add-dialog/member-quick-add-dialog.component';
|
import { MemberQuickAddDialogComponent } from '../../components/member-quick-add-dialog/member-quick-add-dialog.component';
|
||||||
import {
|
import {
|
||||||
GivingCategoryDto, PaymentMethod, OfferingBufferLine, CreateOfferingSessionRequest,
|
GivingCategoryDto, PaymentMethod, OfferingBufferLine, CreateOfferingSessionRequest,
|
||||||
OfferingSessionListItemDto, OfferingSessionDto,
|
OfferingSessionListItemDto, OfferingSessionDto, OfferingGivingLineDto,
|
||||||
} from '../../models/giving.model';
|
} from '../../models/giving.model';
|
||||||
import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';
|
import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||||
|
|
||||||
@@ -34,9 +35,14 @@ type PageMode = 'landing' | 'workspace' | 'view';
|
|||||||
templateUrl: './offering-session-page.component.html',
|
templateUrl: './offering-session-page.component.html',
|
||||||
styleUrls: ['./offering-session-page.component.scss'],
|
styleUrls: ['./offering-session-page.component.scss'],
|
||||||
})
|
})
|
||||||
export class OfferingSessionPageComponent implements OnInit {
|
export class OfferingSessionPageComponent implements OnInit, OnDestroy {
|
||||||
mode: PageMode = 'landing';
|
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();
|
sessionDate: Date = new Date();
|
||||||
dateConflict = false;
|
dateConflict = false;
|
||||||
categories: GivingCategoryDto[] = [];
|
categories: GivingCategoryDto[] = [];
|
||||||
@@ -72,6 +78,7 @@ export class OfferingSessionPageComponent implements OnInit {
|
|||||||
private api: OfferingSessionApiService,
|
private api: OfferingSessionApiService,
|
||||||
private categoryApi: GivingCategoryApiService,
|
private categoryApi: GivingCategoryApiService,
|
||||||
private memberApi: MemberApiService,
|
private memberApi: MemberApiService,
|
||||||
|
private signalr: OfferingEntrySignalrService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -81,6 +88,67 @@ export class OfferingSessionPageComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
this.checkDate();
|
this.checkDate();
|
||||||
this.loadSessions();
|
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); }
|
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.cashTotal = 0; this.checkTotal = 0; this.notes = null;
|
||||||
this.pendingProofFiles = [];
|
this.pendingProofFiles = [];
|
||||||
this.resetEntry();
|
this.resetEntry();
|
||||||
|
this.joinLive(this.toIso(this.sessionDate));
|
||||||
this.mode = 'workspace';
|
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). */
|
/** Open a session read-only (from a Recent Sessions row or a resolved date). */
|
||||||
openView(s: OfferingSessionListItemDto): void {
|
openView(s: OfferingSessionListItemDto): void {
|
||||||
this.api.getById(s.id).subscribe({
|
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.'),
|
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. */
|
/** Leave workspace/view and return to the date-first landing screen. */
|
||||||
backToLanding(): void {
|
backToLanding(): void {
|
||||||
|
this.leaveLive();
|
||||||
this.resetSession();
|
this.resetSession();
|
||||||
this.mode = 'landing';
|
this.mode = 'landing';
|
||||||
this.loadSessions();
|
this.loadSessions();
|
||||||
@@ -276,6 +346,7 @@ export class OfferingSessionPageComponent implements OnInit {
|
|||||||
next: () => {
|
next: () => {
|
||||||
this.submitting = false;
|
this.submitting = false;
|
||||||
alert(isEdit ? 'Offering session updated.' : 'Offering session submitted.');
|
alert(isEdit ? 'Offering session updated.' : 'Offering session submitted.');
|
||||||
|
this.leaveLive();
|
||||||
this.resetSession();
|
this.resetSession();
|
||||||
this.mode = 'landing';
|
this.mode = 'landing';
|
||||||
this.loadSessions();
|
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