add quick add entry.

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