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
@@ -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)