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.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)
|
||||
|
||||
Reference in New Issue
Block a user