add quick add entry.
This commit is contained in:
@@ -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