Merge feature/giving-module: manual giving/donation tracking module

Giving-category config, single giving entry, and keyboard-first Sunday
offering batch entry (OfferingSession) with server-side reconciliation,
lock-after-submit, and finance reopen/replace. 53 backend tests; runtime
E2E verified against dev DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-28 18:10:40 -07:00
53 changed files with 6607 additions and 2 deletions
@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class GivingCategoryServiceTests
{
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return mock.Object;
}
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
[Fact]
public async Task CreateAsync_ReturnsId_AndDefaultsActive()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db);
var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Tithe", Name_zh = "什一" });
var saved = await db.GivingCategories.FindAsync(id);
Assert.NotNull(saved);
Assert.True(saved!.IsActive);
Assert.Equal("Tithe", saved.Name_en);
}
[Fact]
public async Task GetAllAsync_ExcludesInactive_ByDefault()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db);
var id1 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Active" });
var id2 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Gone" });
await svc.DeactivateAsync(id2);
var active = await svc.GetAllAsync(includeInactive: false);
var all = await svc.GetAllAsync(includeInactive: true);
Assert.Single(active);
Assert.Equal(2, all.Count);
}
[Fact]
public async Task DeactivateAsync_SetsIsActiveFalse()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db);
var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Temp" });
await svc.DeactivateAsync(id);
var saved = await db.GivingCategories.FindAsync(id);
Assert.False(saved!.IsActive);
}
[Fact]
public async Task UpdateAsync_Throws_WhenMissing()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db);
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
svc.UpdateAsync(999, new UpdateGivingCategoryRequest { Name_en = "X" }));
}
[Fact]
public async Task DeactivateAsync_Throws_WhenMissing()
{
using var db = BuildDb();
var svc = new GivingCategoryService(db);
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync(999));
}
}
@@ -0,0 +1,161 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class GivingServiceTests
{
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return mock.Object;
}
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedCategoryAsync(AppDbContext db)
{
var c = new GivingCategory { Name_en = "Tithe", IsActive = true };
db.GivingCategories.Add(c);
await db.SaveChangesAsync();
return c.Id;
}
[Fact]
public async Task CreateAsync_PersistsGiving()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new GivingService(db);
var id = await svc.CreateAsync(new CreateGivingRequest
{
GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026, 5, 31), IsAnonymous = true,
});
var saved = await db.Givings.FindAsync(id);
Assert.NotNull(saved);
Assert.Equal(100m, saved!.Amount);
Assert.Null(saved.OfferingSessionId);
}
[Fact]
public async Task GetPagedAsync_FiltersByCategory()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new GivingService(db);
await svc.CreateAsync(new CreateGivingRequest { GivingCategoryId = catId, Amount = 10m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) });
var page = await svc.GetPagedAsync(1, 20, null, catId, null, null);
Assert.Equal(1, page.TotalCount);
Assert.Equal("Tithe", page.Items[0].CategoryName);
}
[Fact]
public async Task UpdateAsync_Throws_WhenGivingBelongsToSubmittedSession()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var session = new OfferingSession { SessionDate = new DateOnly(2026,5,31), Status = "Submitted" };
db.OfferingSessions.Add(session);
await db.SaveChangesAsync();
var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026,5,31), OfferingSessionId = session.Id };
db.Givings.Add(giving);
await db.SaveChangesAsync();
var svc = new GivingService(db);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.UpdateAsync(giving.Id, new UpdateGivingRequest
{ GivingCategoryId = catId, Amount = 999m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) }));
}
[Fact]
public async Task DeleteAsync_Throws_WhenMissing()
{
using var db = BuildDb();
var svc = new GivingService(db);
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeleteAsync(999));
}
[Fact]
public async Task CreateAsync_Anonymous_NullsProvidedMemberId()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new GivingService(db);
var id = await svc.CreateAsync(new CreateGivingRequest
{
GivingCategoryId = catId, Amount = 25m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026, 5, 31),
IsAnonymous = true, MemberId = 12345, // provided, but must be stripped
});
var saved = await db.Givings.FindAsync(id);
Assert.True(saved!.IsAnonymous);
Assert.Null(saved.MemberId);
}
[Fact]
public async Task DeleteAsync_Throws_WhenGivingBelongsToSubmittedSession()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var session = new OfferingSession { SessionDate = new DateOnly(2026, 5, 31), Status = "Submitted" };
db.OfferingSessions.Add(session);
await db.SaveChangesAsync();
var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026, 5, 31), OfferingSessionId = session.Id };
db.Givings.Add(giving);
await db.SaveChangesAsync();
var svc = new GivingService(db);
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.DeleteAsync(giving.Id));
}
[Fact]
public async Task GetPagedAsync_MatchesByMemberName()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var member = new Member { FirstName_en = "Grace", LastName_en = "Lee" };
db.Members.Add(member);
await db.SaveChangesAsync();
var svc = new GivingService(db);
await svc.CreateAsync(new CreateGivingRequest
{
GivingCategoryId = catId, Amount = 75m, PaymentMethod = "Cash",
GivingDate = new DateOnly(2026, 5, 31), MemberId = member.Id,
});
var page = await svc.GetPagedAsync(1, 20, "grace", null, null, null);
Assert.Equal(1, page.TotalCount);
Assert.Equal(member.Id, page.Items[0].MemberId);
}
}
@@ -0,0 +1,155 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class OfferingSessionServiceTests
{
private static IHttpContextAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return mock.Object;
}
private static AppDbContext BuildDb(string userId = "test-user")
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedCategoryAsync(AppDbContext db)
{
var c = new GivingCategory { Name_en = "Tithe", IsActive = true };
db.GivingCategories.Add(c);
await db.SaveChangesAsync();
return c.Id;
}
private static CreateOfferingSessionRequest BuildRequest(int catId, DateOnly date) => new()
{
SessionDate = date, CashTotal = 150m, CheckTotal = 0m,
Givings =
[
new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash" },
new() { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash", IsAnonymous = true },
],
};
[Fact]
public async Task CreateAsync_RecomputesSystemTotalAndDifference_ServerSide()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
var saved = await db.OfferingSessions.FindAsync(id);
Assert.Equal("Submitted", saved!.Status);
Assert.Equal(150m, saved.SystemTotal);
Assert.Equal(0m, saved.Difference);
Assert.NotNull(saved.SubmittedAt);
Assert.Equal(2, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
}
[Fact]
public async Task CreateAsync_LinesGetSessionDateAndAnonymousNullsMember()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
var lines = await db.Givings.Where(g => g.OfferingSessionId == id).ToListAsync();
Assert.All(lines, l => Assert.Equal(new DateOnly(2026,5,31), l.GivingDate));
Assert.Contains(lines, l => l.IsAnonymous && l.MemberId == null);
}
[Fact]
public async Task CreateAsync_Throws_OnDuplicateSessionDate()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor());
await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))));
}
[Fact]
public async Task ReplaceAsync_Throws_WhenSessionIsSubmitted()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.ReplaceAsync(id, BuildRequest(catId, new DateOnly(2026, 5, 31))));
}
[Fact]
public async Task ReopenThenReplace_SwapsLinesAndResubmits()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor());
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
await svc.ReopenAsync(id);
var reopened = await db.OfferingSessions.FindAsync(id);
Assert.Equal("Draft", reopened!.Status);
var newReq = new CreateOfferingSessionRequest
{
SessionDate = new DateOnly(2026,5,31), CashTotal = 200m, CheckTotal = 0m,
Givings = [ new() { GivingCategoryId = catId, Amount = 200m, PaymentMethod = "Cash" } ],
};
await svc.ReplaceAsync(id, newReq);
var after = await db.OfferingSessions.FindAsync(id);
Assert.Equal("Submitted", after!.Status);
Assert.Equal(200m, after.SystemTotal);
Assert.Equal(1, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
}
[Fact]
public async Task GetByIdAsync_ReturnsCheckZelleAndPayPalRefs()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor());
var req = new CreateOfferingSessionRequest
{
SessionDate = new DateOnly(2026, 6, 7), CashTotal = 0m, CheckTotal = 100m,
Givings = [ new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Zelle",
ZelleReferenceCode = "Z-123", PayPalTransactionId = "PP-456", CheckNumber = "C-789" } ],
};
var id = await svc.CreateAsync(req);
var dto = await svc.GetByIdAsync(id);
Assert.NotNull(dto);
var line = Assert.Single(dto!.Givings);
Assert.Equal("Z-123", line.ZelleReferenceCode);
Assert.Equal("PP-456", line.PayPalTransactionId);
Assert.Equal("C-789", line.CheckNumber);
}
}
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/giving-categories")]
[Authorize(Roles = "finance,super_admin")]
public class GivingCategoriesController : ControllerBase
{
private readonly IGivingCategoryService _svc;
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Deactivate(int id)
{
try { await _svc.DeactivateAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/givings")]
[Authorize(Roles = "finance,super_admin")]
public class GivingsController : ControllerBase
{
private readonly IGivingService _svc;
public GivingsController(IGivingService svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try { await _svc.DeleteAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/offering-sessions")]
[Authorize(Roles = "finance,super_admin")]
public class OfferingSessionsController : ControllerBase
{
private readonly IOfferingSessionService _svc;
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
[HttpGet("check-date")]
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
{
try
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id }, new { id });
}
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPost("{id:int}/reopen")]
public async Task<IActionResult> Reopen(int id)
{
try { await _svc.ReopenAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
{
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
}
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class CreateGivingCategoryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public int SortOrder { get; set; }
}
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class CreateGivingRequest
{
public int? MemberId { get; set; }
[Required] public int GivingCategoryId { get; set; }
[Range(0.01, 9999999)] public decimal Amount { get; set; }
[Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash";
[MaxLength(50)] public string? CheckNumber { get; set; }
[MaxLength(100)] public string? ZelleReferenceCode { get; set; }
[MaxLength(100)] public string? PayPalTransactionId { get; set; }
public DateOnly GivingDate { get; set; }
public bool IsAnonymous { get; set; }
[MaxLength(500)] public string? Notes { get; set; }
}
@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class CreateOfferingSessionRequest
{
[Required] public DateOnly SessionDate { get; set; }
public decimal CashTotal { get; set; }
public decimal CheckTotal { get; set; }
public string? Notes { get; set; }
public List<OfferingGivingLineRequest> Givings { get; set; } = [];
}
@@ -0,0 +1,12 @@
namespace ROLAC.API.DTOs.Giving;
public class GivingCategoryDto
{
public int Id { get; set; }
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public string? Description_en { get; set; }
public string? Description_zh { get; set; }
public bool IsActive { get; set; }
public int SortOrder { get; set; }
}
+18
View File
@@ -0,0 +1,18 @@
namespace ROLAC.API.DTOs.Giving;
public class GivingDto
{
public int Id { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public int GivingCategoryId { get; set; }
public int? OfferingSessionId { get; set; }
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = "";
public string? CheckNumber { get; set; }
public string? ZelleReferenceCode { get; set; }
public string? PayPalTransactionId { get; set; }
public DateOnly GivingDate { get; set; }
public bool IsAnonymous { get; set; }
public string? Notes { get; set; }
}
@@ -0,0 +1,15 @@
namespace ROLAC.API.DTOs.Giving;
public class GivingListItemDto
{
public int Id { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public int GivingCategoryId { get; set; }
public string CategoryName { get; set; } = "";
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = "";
public string GivingDate { get; set; } = ""; // ISO yyyy-MM-dd
public bool IsAnonymous { get; set; }
public int? OfferingSessionId { get; set; }
}
@@ -0,0 +1,17 @@
namespace ROLAC.API.DTOs.Giving;
public class OfferingGivingLineDto
{
public int Id { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public int GivingCategoryId { get; set; }
public string CategoryName { get; set; } = "";
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = "";
public string? CheckNumber { get; set; }
public string? ZelleReferenceCode { get; set; }
public string? PayPalTransactionId { get; set; }
public bool IsAnonymous { get; set; }
public string? Notes { get; set; }
}
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class OfferingGivingLineRequest
{
public int? MemberId { get; set; }
[Required] public int GivingCategoryId { get; set; }
[Range(0.01, 9999999)] public decimal Amount { get; set; }
[Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash";
[MaxLength(50)] public string? CheckNumber { get; set; }
[MaxLength(100)] public string? ZelleReferenceCode { get; set; }
[MaxLength(100)] public string? PayPalTransactionId { get; set; }
public bool IsAnonymous { get; set; }
[MaxLength(500)] public string? Notes { get; set; }
}
@@ -0,0 +1,14 @@
namespace ROLAC.API.DTOs.Giving;
public class OfferingSessionDto
{
public int Id { get; set; }
public DateOnly SessionDate{ get; set; }
public string Status { get; set; } = "";
public decimal CashTotal { get; set; }
public decimal CheckTotal { get; set; }
public decimal SystemTotal { get; set; }
public decimal Difference { get; set; }
public string? Notes { get; set; }
public List<OfferingGivingLineDto> Givings { get; set; } = [];
}
@@ -0,0 +1,13 @@
namespace ROLAC.API.DTOs.Giving;
public class OfferingSessionListItemDto
{
public int Id { get; set; }
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
public string Status { get; set; } = "";
public decimal CashTotal { get; set; }
public decimal CheckTotal { get; set; }
public decimal SystemTotal { get; set; }
public decimal Difference { get; set; }
public int LineCount { get; set; }
}
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Giving;
public class UpdateGivingCategoryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
@@ -0,0 +1,3 @@
namespace ROLAC.API.DTOs.Giving;
public class UpdateGivingRequest : CreateGivingRequest { }
+53
View File
@@ -11,6 +11,9 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<Member> Members => Set<Member>(); public DbSet<Member> Members => Set<Member>();
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>(); public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
public DbSet<OfferingSession> OfferingSessions => Set<OfferingSession>();
public DbSet<Giving> Givings => Set<Giving>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@@ -89,5 +92,55 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.HasOne(e => e.FamilyUnit).WithMany() entity.HasOne(e => e.FamilyUnit).WithMany()
.HasForeignKey(e => e.FamilyUnitId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(e => e.FamilyUnitId).OnDelete(DeleteBehavior.SetNull);
}); });
// ── GivingCategory ───────────────────────────────────────────────────
builder.Entity<GivingCategory>(entity =>
{
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.Description_en).HasMaxLength(500);
entity.Property(e => e.Description_zh).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
});
// ── OfferingSession ──────────────────────────────────────────────────
builder.Entity<OfferingSession>(entity =>
{
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Draft");
entity.Property(e => e.CashTotal).HasColumnType("decimal(18,2)");
entity.Property(e => e.CheckTotal).HasColumnType("decimal(18,2)");
entity.Property(e => e.SystemTotal).HasColumnType("decimal(18,2)");
entity.Property(e => e.Difference).HasColumnType("decimal(18,2)");
entity.Property(e => e.SubmittedBy).HasMaxLength(450);
entity.Property(e => e.ReconciledBy).HasMaxLength(450);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.SessionDate).IsUnique();
});
// ── Giving ───────────────────────────────────────────────────────────
builder.Entity<Giving>(entity =>
{
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.PaymentMethod).HasMaxLength(20).IsRequired();
entity.Property(e => e.CheckNumber).HasMaxLength(50);
entity.Property(e => e.ZelleReferenceCode).HasMaxLength(100);
entity.Property(e => e.PayPalTransactionId).HasMaxLength(100);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => new { e.MemberId, e.GivingDate });
entity.HasIndex(e => e.OfferingSessionId).HasFilter("\"OfferingSessionId\" IS NOT NULL");
entity.HasIndex(e => e.GivingDate);
entity.HasOne(e => e.GivingCategory).WithMany()
.HasForeignKey(e => e.GivingCategoryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.OfferingSession).WithMany(s => s.Givings)
.HasForeignKey(e => e.OfferingSessionId).OnDelete(DeleteBehavior.Cascade);
});
} }
} }
+32
View File
@@ -1,10 +1,20 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities; using ROLAC.API.Entities;
namespace ROLAC.API.Data; namespace ROLAC.API.Data;
public static class DbSeeder public static class DbSeeder
{ {
private static readonly (string En, string Zh, int Sort)[] GivingCategorySeed =
[
("Tithe", "什一奉獻", 1),
("General Offering", "一般奉獻", 2),
("Special Offering", "特別奉獻", 3),
("Building Fund", "建堂基金", 4),
("Mission", "宣教奉獻", 5),
];
private static readonly (string Name, string Description)[] Roles = private static readonly (string Name, string Description)[] Roles =
[ [
("super_admin", "System administrator — full access"), ("super_admin", "System administrator — full access"),
@@ -37,6 +47,25 @@ public static class DbSeeder
} }
} }
public static async Task SeedGivingCategoriesAsync(AppDbContext db)
{
foreach (var (en, zh, sort) in GivingCategorySeed)
{
if (!await db.GivingCategories.AnyAsync(c => c.Name_en == en))
{
db.GivingCategories.Add(new GivingCategory
{
Name_en = en,
Name_zh = zh,
SortOrder = sort,
IsActive = true,
// Audit fields are stamped by AuditSaveChangesInterceptor on save.
});
}
}
await db.SaveChangesAsync();
}
/// <summary> /// <summary>
/// Seeds roles and (in Development) the default admin account. /// Seeds roles and (in Development) the default admin account.
/// Called once on application startup after migrations have been applied. /// Called once on application startup after migrations have been applied.
@@ -49,6 +78,9 @@ public static class DbSeeder
await SeedRolesAsync(roleManager); await SeedRolesAsync(roleManager);
var db = services.GetRequiredService<AppDbContext>();
await SeedGivingCategoriesAsync(db);
if (env.IsDevelopment()) if (env.IsDevelopment())
await SeedAdminUserAsync(userManager); await SeedAdminUserAsync(userManager);
} }
+23
View File
@@ -0,0 +1,23 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class Giving : AuditableEntity
{
public int Id { get; set; }
public int? MemberId { get; set; }
public int GivingCategoryId { get; set; }
public int? OfferingSessionId { get; set; }
public decimal Amount { get; set; }
public string PaymentMethod { get; set; } = "Cash"; // Cash|Check|Zelle|PayPal|Other
public string? CheckNumber { get; set; }
public string? ZelleReferenceCode { get; set; }
public string? PayPalTransactionId{ get; set; }
public DateOnly GivingDate { get; set; }
public bool IsAnonymous { get; set; }
public string? Notes { get; set; }
public Member? Member { get; set; }
public GivingCategory? GivingCategory { get; set; }
public OfferingSession? OfferingSession { get; set; }
}
+14
View File
@@ -0,0 +1,14 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class GivingCategory : AuditableEntity
{
public int Id { get; set; }
public string Name_en { get; set; } = null!;
public string? Name_zh { get; set; }
public string? Description_en { get; set; }
public string? Description_zh { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
}
+21
View File
@@ -0,0 +1,21 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
public class OfferingSession : AuditableEntity
{
public int Id { get; set; }
public DateOnly SessionDate { get; set; }
public string Status { get; set; } = "Draft"; // Draft | Submitted | Reconciled
public decimal CashTotal { get; set; }
public decimal CheckTotal { get; set; }
public decimal SystemTotal { get; set; }
public decimal Difference { get; set; }
public string? Notes { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
public string? SubmittedBy { get; set; }
public DateTimeOffset? ReconciledAt { get; set; }
public string? ReconciledBy { get; set; }
public List<Giving> Givings { get; set; } = [];
}
@@ -0,0 +1,792 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using ROLAC.API.Data;
#nullable disable
namespace ROLAC.API.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260528232422_AddGivingModule")]
partial class AddGivingModule
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.AppRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("LanguagePreference")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasDefaultValue("en");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<int?>("MemberId")
.HasColumnType("integer");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("MemberId")
.IsUnique()
.HasFilter("\"MemberId\" IS NOT NULL");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.FamilyUnit", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasColumnType("text");
b.Property<string>("FamilyName_en")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FamilyName_zh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("FamilyUnits");
});
modelBuilder.Entity("ROLAC.API.Entities.Giving", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<string>("CheckNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int>("GivingCategoryId")
.HasColumnType("integer");
b.Property<DateOnly>("GivingDate")
.HasColumnType("date");
b.Property<bool>("IsAnonymous")
.HasColumnType("boolean");
b.Property<int?>("MemberId")
.HasColumnType("integer");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int?>("OfferingSessionId")
.HasColumnType("integer");
b.Property<string>("PayPalTransactionId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PaymentMethod")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("ZelleReferenceCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("GivingCategoryId");
b.HasIndex("GivingDate");
b.HasIndex("OfferingSessionId")
.HasFilter("\"OfferingSessionId\" IS NOT NULL");
b.HasIndex("MemberId", "GivingDate");
b.ToTable("Givings");
});
modelBuilder.Entity("ROLAC.API.Entities.GivingCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Description_en")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Description_zh")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name_en")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name_zh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.ToTable("GivingCategories");
});
modelBuilder.Entity("ROLAC.API.Entities.Member", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("BaptismChurch")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateOnly?>("BaptismDate")
.HasColumnType("date");
b.Property<string>("City")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Country")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasDefaultValue("USA");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<DateOnly?>("DateOfBirth")
.HasColumnType("date");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeletedBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("FamilyUnitId")
.HasColumnType("integer");
b.Property<string>("FirstName_en")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("FirstName_zh")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Gender")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<DateOnly?>("JoinDate")
.HasColumnType("date");
b.Property<string>("LanguagePreference")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasDefaultValue("en");
b.Property<string>("LastName_en")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("LastName_zh")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("NickName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("PhoneCell")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("PhoneHome")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("PhotoBlobPath")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("State")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Member");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("ZipCode")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("Email")
.HasFilter("\"Email\" IS NOT NULL");
b.HasIndex("FamilyUnitId");
b.HasIndex("Status")
.HasFilter("\"IsDeleted\" = false");
b.ToTable("Members");
});
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("CashTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("CheckTotal")
.HasColumnType("decimal(18,2)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<decimal>("Difference")
.HasColumnType("decimal(18,2)");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<DateTimeOffset?>("ReconciledAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReconciledBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<DateOnly>("SessionDate")
.HasColumnType("date");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Draft");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SubmittedBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<decimal>("SystemTotal")
.HasColumnType("decimal(18,2)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("SessionDate")
.IsUnique();
b.ToTable("OfferingSessions");
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeviceInfo")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
b.Property<string>("ReplacedByHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ROLAC.API.Entities.Giving", b =>
{
b.HasOne("ROLAC.API.Entities.GivingCategory", "GivingCategory")
.WithMany()
.HasForeignKey("GivingCategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ROLAC.API.Entities.OfferingSession", "OfferingSession")
.WithMany("Givings")
.HasForeignKey("OfferingSessionId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("GivingCategory");
b.Navigation("Member");
b.Navigation("OfferingSession");
});
modelBuilder.Entity("ROLAC.API.Entities.Member", b =>
{
b.HasOne("ROLAC.API.Entities.FamilyUnit", "FamilyUnit")
.WithMany()
.HasForeignKey("FamilyUnitId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("FamilyUnit");
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
{
b.Navigation("Givings");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,150 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddGivingModule : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GivingCategories",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name_en = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Name_zh = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Description_en = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
Description_zh = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GivingCategories", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OfferingSessions",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SessionDate = table.Column<DateOnly>(type: "date", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
CashTotal = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
CheckTotal = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
SystemTotal = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
Difference = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
Notes = table.Column<string>(type: "text", nullable: true),
SubmittedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
SubmittedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true),
ReconciledAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ReconciledBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OfferingSessions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Givings",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MemberId = table.Column<int>(type: "integer", nullable: true),
GivingCategoryId = table.Column<int>(type: "integer", nullable: false),
OfferingSessionId = table.Column<int>(type: "integer", nullable: true),
Amount = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
PaymentMethod = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
CheckNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ZelleReferenceCode = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
PayPalTransactionId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
GivingDate = table.Column<DateOnly>(type: "date", nullable: false),
IsAnonymous = table.Column<bool>(type: "boolean", nullable: false),
Notes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Givings", x => x.Id);
table.ForeignKey(
name: "FK_Givings_GivingCategories_GivingCategoryId",
column: x => x.GivingCategoryId,
principalTable: "GivingCategories",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Givings_Members_MemberId",
column: x => x.MemberId,
principalTable: "Members",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Givings_OfferingSessions_OfferingSessionId",
column: x => x.OfferingSessionId,
principalTable: "OfferingSessions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Givings_GivingCategoryId",
table: "Givings",
column: "GivingCategoryId");
migrationBuilder.CreateIndex(
name: "IX_Givings_GivingDate",
table: "Givings",
column: "GivingDate");
migrationBuilder.CreateIndex(
name: "IX_Givings_MemberId_GivingDate",
table: "Givings",
columns: new[] { "MemberId", "GivingDate" });
migrationBuilder.CreateIndex(
name: "IX_Givings_OfferingSessionId",
table: "Givings",
column: "OfferingSessionId",
filter: "\"OfferingSessionId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_OfferingSessions_SessionDate",
table: "OfferingSessions",
column: "SessionDate",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Givings");
migrationBuilder.DropTable(
name: "GivingCategories");
migrationBuilder.DropTable(
name: "OfferingSessions");
}
}
}
@@ -283,6 +283,135 @@ namespace ROLAC.API.Migrations
b.ToTable("FamilyUnits"); b.ToTable("FamilyUnits");
}); });
modelBuilder.Entity("ROLAC.API.Entities.Giving", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<string>("CheckNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int>("GivingCategoryId")
.HasColumnType("integer");
b.Property<DateOnly>("GivingDate")
.HasColumnType("date");
b.Property<bool>("IsAnonymous")
.HasColumnType("boolean");
b.Property<int?>("MemberId")
.HasColumnType("integer");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int?>("OfferingSessionId")
.HasColumnType("integer");
b.Property<string>("PayPalTransactionId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PaymentMethod")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("ZelleReferenceCode")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("GivingCategoryId");
b.HasIndex("GivingDate");
b.HasIndex("OfferingSessionId")
.HasFilter("\"OfferingSessionId\" IS NOT NULL");
b.HasIndex("MemberId", "GivingDate");
b.ToTable("Givings");
});
modelBuilder.Entity("ROLAC.API.Entities.GivingCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Description_en")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Description_zh")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name_en")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name_zh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.ToTable("GivingCategories");
});
modelBuilder.Entity("ROLAC.API.Entities.Member", b => modelBuilder.Entity("ROLAC.API.Entities.Member", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -428,6 +557,77 @@ namespace ROLAC.API.Migrations
b.ToTable("Members"); b.ToTable("Members");
}); });
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("CashTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("CheckTotal")
.HasColumnType("decimal(18,2)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<decimal>("Difference")
.HasColumnType("decimal(18,2)");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<DateTimeOffset?>("ReconciledAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReconciledBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<DateOnly>("SessionDate")
.HasColumnType("date");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Draft");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SubmittedBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<decimal>("SystemTotal")
.HasColumnType("decimal(18,2)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("SessionDate")
.IsUnique();
b.ToTable("OfferingSessions");
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b => modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -528,6 +728,31 @@ namespace ROLAC.API.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("ROLAC.API.Entities.Giving", b =>
{
b.HasOne("ROLAC.API.Entities.GivingCategory", "GivingCategory")
.WithMany()
.HasForeignKey("GivingCategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ROLAC.API.Entities.OfferingSession", "OfferingSession")
.WithMany("Givings")
.HasForeignKey("OfferingSessionId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("GivingCategory");
b.Navigation("Member");
b.Navigation("OfferingSession");
});
modelBuilder.Entity("ROLAC.API.Entities.Member", b => modelBuilder.Entity("ROLAC.API.Entities.Member", b =>
{ {
b.HasOne("ROLAC.API.Entities.FamilyUnit", "FamilyUnit") b.HasOne("ROLAC.API.Entities.FamilyUnit", "FamilyUnit")
@@ -553,6 +778,11 @@ namespace ROLAC.API.Migrations
{ {
b.Navigation("RefreshTokens"); b.Navigation("RefreshTokens");
}); });
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
{
b.Navigation("Givings");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
+3
View File
@@ -118,6 +118,9 @@ builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IMemberService, MemberService>(); builder.Services.AddScoped<IMemberService, MemberService>();
builder.Services.AddScoped<IUserManagementService, UserManagementService>(); builder.Services.AddScoped<IUserManagementService, UserManagementService>();
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
builder.Services.AddScoped<IGivingService, GivingService>();
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Swagger / MVC // Swagger / MVC
@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class GivingCategoryService : IGivingCategoryService
{
private readonly AppDbContext _db;
public GivingCategoryService(AppDbContext db) => _db = db;
public async Task<List<GivingCategoryDto>> GetAllAsync(bool includeInactive)
{
var query = _db.GivingCategories.AsNoTracking().AsQueryable();
if (!includeInactive) query = query.Where(c => c.IsActive);
return await query
.OrderBy(c => c.SortOrder).ThenBy(c => c.Name_en)
.Select(c => new GivingCategoryDto
{
Id = c.Id, Name_en = c.Name_en, Name_zh = c.Name_zh,
Description_en = c.Description_en, Description_zh = c.Description_zh,
IsActive = c.IsActive, SortOrder = c.SortOrder,
})
.ToListAsync();
}
public async Task<int> CreateAsync(CreateGivingCategoryRequest r)
{
var entity = new GivingCategory
{
Name_en = r.Name_en, Name_zh = r.Name_zh,
Description_en = r.Description_en, Description_zh = r.Description_zh,
SortOrder = r.SortOrder, IsActive = true,
};
_db.GivingCategories.Add(entity);
await _db.SaveChangesAsync();
return entity.Id;
}
public async Task UpdateAsync(int id, UpdateGivingCategoryRequest r)
{
var c = await _db.GivingCategories.FindAsync(id)
?? throw new KeyNotFoundException($"GivingCategory {id} not found.");
c.Name_en = r.Name_en; c.Name_zh = r.Name_zh;
c.Description_en = r.Description_en; c.Description_zh = r.Description_zh;
c.IsActive = r.IsActive; c.SortOrder = r.SortOrder;
await _db.SaveChangesAsync();
}
public async Task DeactivateAsync(int id)
{
var c = await _db.GivingCategories.FindAsync(id)
?? throw new KeyNotFoundException($"GivingCategory {id} not found.");
c.IsActive = false;
await _db.SaveChangesAsync();
}
}
+132
View File
@@ -0,0 +1,132 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class GivingService : IGivingService
{
private readonly AppDbContext _db;
public GivingService(AppDbContext db) => _db = db;
public async Task<PagedResult<GivingListItemDto>> GetPagedAsync(
int page, int pageSize, string? search, int? categoryId, DateOnly? from, DateOnly? to)
{
var query = _db.Givings.AsNoTracking().AsQueryable();
if (categoryId.HasValue) query = query.Where(g => g.GivingCategoryId == categoryId.Value);
if (from.HasValue) query = query.Where(g => g.GivingDate >= from.Value);
if (to.HasValue) query = query.Where(g => g.GivingDate <= to.Value);
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
var term = search.Trim();
query = query.Where(g =>
(g.CheckNumber != null && g.CheckNumber.ToLower().Contains(s)) ||
(g.Notes != null && g.Notes.ToLower().Contains(s)) ||
(g.Member != null && (
(g.Member.FirstName_en + " " + g.Member.LastName_en).ToLower().Contains(s) ||
(g.Member.FirstName_zh != null && g.Member.FirstName_zh.Contains(term)) ||
(g.Member.LastName_zh != null && g.Member.LastName_zh.Contains(term)))));
}
var total = await query.CountAsync();
var rows = await query
.OrderByDescending(g => g.GivingDate).ThenByDescending(g => g.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.ToListAsync();
var catNames = await _db.GivingCategories.AsNoTracking()
.ToDictionaryAsync(c => c.Id, c => c.Name_en);
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.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}");
var items = rows.Select(g => new GivingListItemDto
{
Id = g.Id, MemberId = g.MemberId,
MemberName = g.MemberId != null && memberNames.TryGetValue(g.MemberId.Value, out var n) ? n : null,
GivingCategoryId = g.GivingCategoryId,
CategoryName = catNames.TryGetValue(g.GivingCategoryId, out var cn) ? cn : "",
Amount = g.Amount, PaymentMethod = g.PaymentMethod,
GivingDate = g.GivingDate.ToString("yyyy-MM-dd"),
IsAnonymous = g.IsAnonymous, OfferingSessionId = g.OfferingSessionId,
}).ToList();
return new PagedResult<GivingListItemDto>
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
public async Task<GivingDto?> GetByIdAsync(int id)
{
var g = await _db.Givings.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (g is null) return null;
string? memberName = null;
if (g.MemberId != null)
memberName = await _db.Members.AsNoTracking()
.Where(m => m.Id == g.MemberId)
.Select(m => m.FirstName_en + " " + m.LastName_en)
.FirstOrDefaultAsync();
return new GivingDto
{
Id = g.Id, MemberId = g.MemberId, MemberName = memberName,
GivingCategoryId = g.GivingCategoryId, OfferingSessionId = g.OfferingSessionId,
Amount = g.Amount, PaymentMethod = g.PaymentMethod, CheckNumber = g.CheckNumber,
ZelleReferenceCode = g.ZelleReferenceCode, PayPalTransactionId = g.PayPalTransactionId,
GivingDate = g.GivingDate, IsAnonymous = g.IsAnonymous, Notes = g.Notes,
};
}
public async Task<int> CreateAsync(CreateGivingRequest r)
{
var g = MapFromRequest(new Giving(), r);
g.OfferingSessionId = null;
_db.Givings.Add(g);
await _db.SaveChangesAsync();
return g.Id;
}
public async Task UpdateAsync(int id, UpdateGivingRequest r)
{
var g = await _db.Givings.FindAsync(id)
?? throw new KeyNotFoundException($"Giving {id} not found.");
await GuardSessionNotLockedAsync(g.OfferingSessionId);
MapFromRequest(g, r);
await _db.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var g = await _db.Givings.FindAsync(id)
?? throw new KeyNotFoundException($"Giving {id} not found.");
await GuardSessionNotLockedAsync(g.OfferingSessionId);
_db.Givings.Remove(g);
await _db.SaveChangesAsync();
}
private async Task GuardSessionNotLockedAsync(int? sessionId)
{
if (sessionId is null) return;
var status = await _db.OfferingSessions
.Where(s => s.Id == sessionId).Select(s => s.Status).FirstOrDefaultAsync();
if (status is "Submitted" or "Reconciled")
throw new InvalidOperationException(
"This giving belongs to a locked offering session. Reopen the session to edit.");
}
private static Giving MapFromRequest(Giving g, CreateGivingRequest r)
{
g.MemberId = r.IsAnonymous ? null : r.MemberId;
g.GivingCategoryId = r.GivingCategoryId;
g.Amount = r.Amount; g.PaymentMethod = r.PaymentMethod;
g.CheckNumber = r.CheckNumber; g.ZelleReferenceCode = r.ZelleReferenceCode;
g.PayPalTransactionId = r.PayPalTransactionId; g.GivingDate = r.GivingDate;
g.IsAnonymous = r.IsAnonymous; g.Notes = r.Notes;
return g;
}
}
@@ -0,0 +1,11 @@
using ROLAC.API.DTOs.Giving;
namespace ROLAC.API.Services;
public interface IGivingCategoryService
{
Task<List<GivingCategoryDto>> GetAllAsync(bool includeInactive);
Task<int> CreateAsync(CreateGivingCategoryRequest request);
Task UpdateAsync(int id, UpdateGivingCategoryRequest request);
Task DeactivateAsync(int id); // soft-disable: IsActive = false
}
+14
View File
@@ -0,0 +1,14 @@
using ROLAC.API.DTOs.Giving;
using ROLAC.API.DTOs.Shared;
namespace ROLAC.API.Services;
public interface IGivingService
{
Task<PagedResult<GivingListItemDto>> GetPagedAsync(
int page, int pageSize, string? search, int? categoryId, DateOnly? from, DateOnly? to);
Task<GivingDto?> GetByIdAsync(int id);
Task<int> CreateAsync(CreateGivingRequest request);
Task UpdateAsync(int id, UpdateGivingRequest request);
Task DeleteAsync(int id);
}
@@ -0,0 +1,15 @@
using ROLAC.API.DTOs.Giving;
using ROLAC.API.DTOs.Shared;
namespace ROLAC.API.Services;
public interface IOfferingSessionService
{
Task<PagedResult<OfferingSessionListItemDto>> GetPagedAsync(
int page, int pageSize, DateOnly? from, DateOnly? to);
Task<OfferingSessionDto?> GetByIdAsync(int id);
Task<bool> DateExistsAsync(DateOnly date);
Task<int> CreateAsync(CreateOfferingSessionRequest request);
Task ReopenAsync(int id);
Task ReplaceAsync(int id, CreateOfferingSessionRequest request);
}
@@ -0,0 +1,174 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class OfferingSessionService : IOfferingSessionService
{
private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http;
public OfferingSessionService(AppDbContext db, IHttpContextAccessor http)
{
_db = db;
_http = http;
}
private string CurrentUserId =>
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
public async Task<PagedResult<OfferingSessionListItemDto>> GetPagedAsync(
int page, int pageSize, DateOnly? from, DateOnly? to)
{
var query = _db.OfferingSessions.AsNoTracking().AsQueryable();
if (from.HasValue) query = query.Where(s => s.SessionDate >= from.Value);
if (to.HasValue) query = query.Where(s => s.SessionDate <= to.Value);
var total = await query.CountAsync();
var rows = await query
.OrderByDescending(s => s.SessionDate)
.Skip((page - 1) * pageSize).Take(pageSize)
.ToListAsync();
var ids = rows.Select(r => r.Id).ToList();
var counts = await _db.Givings.AsNoTracking()
.Where(g => g.OfferingSessionId != null && ids.Contains(g.OfferingSessionId.Value))
.GroupBy(g => g.OfferingSessionId!.Value)
.Select(grp => new { Id = grp.Key, Count = grp.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Count);
var items = rows.Select(s => new OfferingSessionListItemDto
{
Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status,
CashTotal = s.CashTotal, CheckTotal = s.CheckTotal,
SystemTotal = s.SystemTotal, Difference = s.Difference,
LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0,
}).ToList();
return new PagedResult<OfferingSessionListItemDto>
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
public async Task<bool> DateExistsAsync(DateOnly date)
=> await _db.OfferingSessions.AnyAsync(s => s.SessionDate == date);
// Distinguishes a unique-index collision on SessionDate (concurrent insert) from other DB errors.
private async Task<bool> DateExistsConcurrentlyAsync(DateOnly date, int excludeId)
=> await _db.OfferingSessions.AsNoTracking().AnyAsync(s => s.SessionDate == date && s.Id != excludeId);
public async Task<OfferingSessionDto?> GetByIdAsync(int id)
{
var s = await _db.OfferingSessions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (s is null) return null;
var lines = await _db.Givings.AsNoTracking()
.Where(g => g.OfferingSessionId == id).ToListAsync();
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 new OfferingSessionDto
{
Id = s.Id, SessionDate = s.SessionDate, Status = s.Status,
CashTotal = s.CashTotal, CheckTotal = s.CheckTotal,
SystemTotal = s.SystemTotal, Difference = s.Difference, Notes = s.Notes,
Givings = 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(),
};
}
public async Task<int> CreateAsync(CreateOfferingSessionRequest r)
{
if (await DateExistsAsync(r.SessionDate))
throw new InvalidOperationException($"An offering session for {r.SessionDate:yyyy-MM-dd} already exists.");
var systemTotal = r.Givings.Sum(g => g.Amount);
var session = new OfferingSession
{
SessionDate = r.SessionDate, Status = "Submitted",
CashTotal = r.CashTotal, CheckTotal = r.CheckTotal,
SystemTotal = systemTotal,
Difference = (r.CashTotal + r.CheckTotal) - systemTotal,
Notes = r.Notes,
SubmittedAt = DateTimeOffset.UtcNow, SubmittedBy = CurrentUserId,
Givings = r.Givings.Select(line => MapLine(line, r.SessionDate)).ToList(),
};
_db.OfferingSessions.Add(session);
try
{
await _db.SaveChangesAsync();
}
catch (DbUpdateException)
{
if (await DateExistsConcurrentlyAsync(r.SessionDate, session.Id))
throw new InvalidOperationException($"An offering session for {r.SessionDate:yyyy-MM-dd} already exists.");
throw;
}
return session.Id;
}
public async Task ReopenAsync(int id)
{
var s = await _db.OfferingSessions.FindAsync(id)
?? throw new KeyNotFoundException($"OfferingSession {id} not found.");
if (s.Status != "Submitted")
throw new InvalidOperationException($"Only a Submitted session can be reopened (current: {s.Status}).");
s.Status = "Draft";
s.SubmittedAt = null; s.SubmittedBy = null;
await _db.SaveChangesAsync();
}
public async Task ReplaceAsync(int id, CreateOfferingSessionRequest r)
{
var s = await _db.OfferingSessions
.Include(x => x.Givings)
.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"OfferingSession {id} not found.");
if (s.Status != "Draft")
throw new InvalidOperationException($"Only a Draft (reopened) session can be edited (current: {s.Status}).");
_db.Givings.RemoveRange(s.Givings);
var systemTotal = r.Givings.Sum(g => g.Amount);
s.CashTotal = r.CashTotal; s.CheckTotal = r.CheckTotal;
s.SystemTotal = systemTotal;
s.Difference = (r.CashTotal + r.CheckTotal) - systemTotal;
s.Notes = r.Notes;
s.Status = "Submitted";
s.SubmittedAt = DateTimeOffset.UtcNow; s.SubmittedBy = CurrentUserId;
s.Givings = r.Givings.Select(line => MapLine(line, s.SessionDate)).ToList();
await _db.SaveChangesAsync();
}
private static Giving MapLine(OfferingGivingLineRequest line, DateOnly sessionDate) => new()
{
MemberId = line.IsAnonymous ? null : line.MemberId,
GivingCategoryId = line.GivingCategoryId,
Amount = line.Amount,
PaymentMethod = line.PaymentMethod,
CheckNumber = line.CheckNumber,
ZelleReferenceCode = line.ZelleReferenceCode,
PayPalTransactionId = line.PayPalTransactionId,
GivingDate = sessionDate,
IsAnonymous = line.IsAnonymous,
Notes = line.Notes,
};
}
+21
View File
@@ -6,6 +6,9 @@ import { AuthGuard } from './core/guards/auth.guard';
import { RoleGuard } from './core/guards/role.guard'; import { RoleGuard } from './core/guards/role.guard';
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component'; import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component'; import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component';
import { GivingsPageComponent } from './features/giving/pages/givings-page/givings-page.component';
import { OfferingSessionPageComponent } from './features/giving/pages/offering-session-page/offering-session-page.component';
export const routes: Routes = [ export const routes: Routes = [
// Public routes // Public routes
@@ -26,6 +29,24 @@ export const routes: Routes = [
canActivate: [RoleGuard], canActivate: [RoleGuard],
data: { roles: ['super_admin'] }, data: { roles: ['super_admin'] },
}, },
{
path: 'finance/giving-categories',
component: GivingCategoriesPageComponent,
canActivate: [RoleGuard],
data: { roles: ['finance', 'super_admin'] },
},
{
path: 'finance/givings',
component: GivingsPageComponent,
canActivate: [RoleGuard],
data: { roles: ['finance', 'super_admin'] },
},
{
path: 'finance/offering-session',
component: OfferingSessionPageComponent,
canActivate: [RoleGuard],
data: { roles: ['finance', 'super_admin'] },
},
] ]
}, },
@@ -0,0 +1,13 @@
<kendo-dialog title="Quick add member" (close)="cancelled.emit()" [width]="420">
<div style="display:flex;flex-direction:column;gap:0.75rem;">
<label>First name (EN) *<kendo-textbox [(ngModel)]="firstName_en"></kendo-textbox></label>
<label>Last name (EN) *<kendo-textbox [(ngModel)]="lastName_en"></kendo-textbox></label>
<label>名 (中)<kendo-textbox [(ngModel)]="firstName_zh"></kendo-textbox></label>
<label>姓 (中)<kendo-textbox [(ngModel)]="lastName_zh"></kendo-textbox></label>
<label>Cell phone<kendo-textbox [(ngModel)]="phoneCell"></kendo-textbox></label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="cancelled.emit()">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!firstName_en || !lastName_en || saving" (click)="save()">Create</button>
</kendo-dialog-actions>
</kendo-dialog>
@@ -0,0 +1,76 @@
import { Component, EventEmitter, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { MemberApiService } from '../../../members/services/member-api.service';
import { CreateMemberRequest, MemberListItemDto } from '../../../members/models/member.model';
@Component({
selector: 'app-member-quick-add-dialog',
standalone: true,
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule],
templateUrl: './member-quick-add-dialog.component.html',
})
export class MemberQuickAddDialogComponent {
@Output() created = new EventEmitter<MemberListItemDto>();
@Output() cancelled = new EventEmitter<void>();
firstName_en = '';
lastName_en = '';
firstName_zh: string | null = null;
lastName_zh: string | null = null;
phoneCell: string | null = null;
saving = false;
constructor(private memberApi: MemberApiService) {}
save(): void {
if (!this.firstName_en || !this.lastName_en) return;
this.saving = true;
const req: CreateMemberRequest = {
firstName_en: this.firstName_en,
lastName_en: this.lastName_en,
nickName: null,
firstName_zh: this.firstName_zh,
lastName_zh: this.lastName_zh,
gender: null,
dateOfBirth: null,
baptismDate: null,
baptismChurch: null,
email: null,
phoneCell: this.phoneCell,
phoneHome: null,
address: null,
city: null,
state: null,
zipCode: null,
country: 'USA',
status: 'Visitor',
languagePreference: 'en',
joinDate: null,
notes: null,
familyUnitId: null,
};
this.memberApi.create(req).subscribe({
next: ({ id }) => {
this.saving = false;
this.created.emit({
id,
firstName_en: this.firstName_en,
lastName_en: this.lastName_en,
nickName: null,
firstName_zh: this.firstName_zh,
lastName_zh: this.lastName_zh,
status: 'Visitor',
email: null,
phoneCell: this.phoneCell,
joinDate: null,
linkedUserId: null,
});
},
error: () => { this.saving = false; },
});
}
}
@@ -0,0 +1,119 @@
export type PaymentMethod = 'Cash' | 'Check' | 'Zelle' | 'PayPal' | 'Other';
export type SessionStatus = 'Draft' | 'Submitted' | 'Reconciled';
export interface PagedResult<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
totalPages: number;
}
// ── Giving categories ─────────────────────────────────────────────
export interface GivingCategoryDto {
id: number;
name_en: string;
name_zh: string | null;
description_en: string | null;
description_zh: string | null;
isActive: boolean;
sortOrder: number;
}
export interface CreateGivingCategoryRequest {
name_en: string;
name_zh: string | null;
description_en: string | null;
description_zh: string | null;
sortOrder: number;
}
export interface UpdateGivingCategoryRequest extends CreateGivingCategoryRequest {
isActive: boolean;
}
// ── Single giving ─────────────────────────────────────────────────
export interface GivingListItemDto {
id: number;
memberId: number | null;
memberName: string | null;
givingCategoryId: number;
categoryName: string;
amount: number;
paymentMethod: PaymentMethod;
givingDate: string; // yyyy-MM-dd
isAnonymous: boolean;
offeringSessionId: number | null;
}
export interface CreateGivingRequest {
memberId: number | null;
givingCategoryId: number;
amount: number;
paymentMethod: PaymentMethod;
checkNumber: string | null;
zelleReferenceCode: string | null;
payPalTransactionId: string | null;
givingDate: string; // yyyy-MM-dd
isAnonymous: boolean;
notes: string | null;
}
export type UpdateGivingRequest = CreateGivingRequest;
// ── Offering session (batch) ──────────────────────────────────────
export interface OfferingGivingLineRequest {
memberId: number | null;
givingCategoryId: number;
amount: number;
paymentMethod: PaymentMethod;
checkNumber: string | null;
zelleReferenceCode: string | null;
payPalTransactionId: string | null;
isAnonymous: boolean;
notes: string | null;
}
export interface CreateOfferingSessionRequest {
sessionDate: string; // yyyy-MM-dd
cashTotal: number;
checkTotal: number;
notes: string | null;
givings: OfferingGivingLineRequest[];
}
export interface OfferingGivingLineDto {
id: number;
memberId: number | null;
memberName: string | null;
givingCategoryId: number;
categoryName: string;
amount: number;
paymentMethod: PaymentMethod;
checkNumber: string | null;
zelleReferenceCode: string | null;
payPalTransactionId: string | null;
isAnonymous: boolean;
notes: string | null;
}
export interface OfferingSessionDto {
id: number;
sessionDate: string;
status: SessionStatus;
cashTotal: number;
checkTotal: number;
systemTotal: number;
difference: number;
notes: string | null;
givings: OfferingGivingLineDto[];
}
export interface OfferingSessionListItemDto {
id: number;
sessionDate: string;
status: SessionStatus;
cashTotal: number;
checkTotal: number;
systemTotal: number;
difference: number;
lineCount: number;
}
/** A row held in the client-side batch buffer before submit. */
export interface OfferingBufferLine extends OfferingGivingLineRequest {
memberName: string | null; // for display only
categoryName: string; // for display only
}
@@ -0,0 +1,58 @@
<div class="page">
<header class="page-header">
<h2>Giving Types / 奉獻類型</h2>
<div class="header-actions">
<label class="inactive-toggle">
<input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive
</label>
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add</button>
</div>
</header>
<kendo-grid [data]="data" [loading]="isLoading">
<kendo-grid-column field="sortOrder" title="#" [width]="60"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
<kendo-grid-column field="isActive" title="Active" [width]="90">
<ng-template kendoGridCellTemplate let-c>{{ c.isActive ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="160">
<ng-template kendoGridCellTemplate let-c>
<button kendoButton fillMode="flat" (click)="openEdit(c)">Edit</button>
<button kendoButton fillMode="flat" *ngIf="c.isActive" (click)="deactivate(c)">Deactivate</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-dialog *ngIf="showDialog" [title]="editing ? 'Edit Giving Type' : 'Add Giving Type'" (close)="showDialog=false" [width]="480">
<div class="form-grid">
<label>
Name (EN) *
<kendo-textbox [(ngModel)]="form.name_en"></kendo-textbox>
</label>
<label>
名稱 (中)
<kendo-textbox [(ngModel)]="form.name_zh"></kendo-textbox>
</label>
<label>
Description (EN)
<kendo-textbox [(ngModel)]="form.description_en"></kendo-textbox>
</label>
<label>
說明 (中)
<kendo-textbox [(ngModel)]="form.description_zh"></kendo-textbox>
</label>
<label>
Sort order
<kendo-numerictextbox [(ngModel)]="form.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label *ngIf="editing">
<input type="checkbox" [(ngModel)]="form.isActive" /> Active
</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="showDialog=false">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!form.name_en" (click)="save()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
@@ -0,0 +1,31 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.inactive-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
}
.form-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-grid label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
@@ -0,0 +1,74 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { GivingCategoryApiService } from '../../services/giving-category-api.service';
import {
GivingCategoryDto, CreateGivingCategoryRequest, UpdateGivingCategoryRequest,
} from '../../models/giving.model';
@Component({
selector: 'app-giving-categories-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, DialogsModule],
templateUrl: './giving-categories-page.component.html',
styleUrls: ['./giving-categories-page.component.scss'],
})
export class GivingCategoriesPageComponent implements OnInit {
data: GivingCategoryDto[] = [];
isLoading = false;
includeInactive = false;
showDialog = false;
editing: GivingCategoryDto | null = null;
form: UpdateGivingCategoryRequest = this.blankForm();
constructor(private api: GivingCategoryApiService) {}
ngOnInit(): void { this.load(); }
load(): void {
this.isLoading = true;
this.api.getAll(this.includeInactive).subscribe({
next: rows => { this.data = rows; this.isLoading = false; },
error: () => { this.isLoading = false; },
});
}
openAdd(): void { this.editing = null; this.form = this.blankForm(); this.showDialog = true; }
openEdit(c: GivingCategoryDto): void {
this.editing = c;
this.form = {
name_en: c.name_en, name_zh: c.name_zh,
description_en: c.description_en, description_zh: c.description_zh,
isActive: c.isActive, sortOrder: c.sortOrder,
};
this.showDialog = true;
}
save(): void {
if (this.editing) {
this.api.update(this.editing.id, this.form).subscribe(() => { this.showDialog = false; this.load(); });
} else {
const create: CreateGivingCategoryRequest = {
name_en: this.form.name_en, name_zh: this.form.name_zh,
description_en: this.form.description_en, description_zh: this.form.description_zh,
sortOrder: this.form.sortOrder,
};
this.api.create(create).subscribe(() => { this.showDialog = false; this.load(); });
}
}
deactivate(c: GivingCategoryDto): void {
if (!confirm(`Deactivate "${c.name_en}"?`)) return;
this.api.deactivate(c.id).subscribe(() => this.load());
}
private blankForm(): UpdateGivingCategoryRequest {
return { name_en: '', name_zh: null, description_en: null, description_zh: null, isActive: true, sortOrder: 0 };
}
}
@@ -0,0 +1,85 @@
<div class="page">
<header class="page-header">
<h2>Givings / 單筆奉獻</h2>
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add Giving</button>
</header>
<div class="filters">
<kendo-textbox placeholder="Search name / check # / notes" [(ngModel)]="search" (keydown.enter)="onSearch()"></kendo-textbox>
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id"
[valuePrimitive]="true" [(ngModel)]="filterCategoryId" (valueChange)="onSearch()"
[defaultItem]="{ id: null, name_en: 'All types' }"></kendo-dropdownlist>
<button kendoButton (click)="onSearch()">Search</button>
</div>
<kendo-grid [data]="gridData" [loading]="isLoading"
[pageable]="true" [skip]="(page-1)*pageSize" [pageSize]="pageSize"
(pageChange)="onPageChange($event)">
<kendo-grid-column field="givingDate" title="Date" [width]="110"></kendo-grid-column>
<kendo-grid-column title="Giver">
<ng-template kendoGridCellTemplate let-g>{{ g.isAnonymous ? '(Anonymous)' : g.memberName }}</ng-template>
</kendo-grid-column>
<kendo-grid-column field="categoryName" title="Type"></kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column field="paymentMethod" title="Method" [width]="100"></kendo-grid-column>
<kendo-grid-column title="Actions" [width]="120">
<ng-template kendoGridCellTemplate let-g>
<button kendoButton fillMode="flat" (click)="delete(g)">Delete</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-dialog *ngIf="showDialog" title="Add Giving" (close)="showDialog=false" [width]="520">
<div class="form-grid">
<label>
<input type="checkbox" [ngModel]="form.isAnonymous" (ngModelChange)="toggleAnonymous()" /> Anonymous
</label>
<label *ngIf="!form.isAnonymous">Giver
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id"
[valuePrimitive]="true" [filterable]="true"
(filterChange)="onMemberFilter($event)"
[(ngModel)]="selectedMemberId"
(valueChange)="onMemberIdSelected($event)"
placeholder="Search member by name"></kendo-dropdownlist>
</label>
<label>Type
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id"
[valuePrimitive]="true" [(ngModel)]="form.givingCategoryId"></kendo-dropdownlist>
</label>
<label>Payment method
<kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="form.paymentMethod"></kendo-dropdownlist>
</label>
<label *ngIf="form.paymentMethod === 'Check'">Check #
<kendo-textbox [(ngModel)]="form.checkNumber"></kendo-textbox>
</label>
<label *ngIf="form.paymentMethod === 'Zelle'">Zelle ref
<kendo-textbox [(ngModel)]="form.zelleReferenceCode"></kendo-textbox>
</label>
<label *ngIf="form.paymentMethod === 'PayPal'">PayPal txn
<kendo-textbox [(ngModel)]="form.payPalTransactionId"></kendo-textbox>
</label>
<label>Amount
<kendo-numerictextbox [(ngModel)]="form.amount" [min]="0" [format]="'c2'"></kendo-numerictextbox>
</label>
<label>Date
<kendo-datepicker [(ngModel)]="givingDateValue"></kendo-datepicker>
</label>
<label>Notes
<kendo-textbox [(ngModel)]="form.notes"></kendo-textbox>
</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="showDialog=false">Cancel</button>
<button kendoButton themeColor="primary"
[disabled]="form.amount <= 0 || (form.paymentMethod==='Check' && !form.checkNumber)"
(click)="save()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
@@ -0,0 +1,4 @@
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.form-grid { display: flex; flex-direction: column; gap: 0.75rem; }
.form-grid label { display: flex; flex-direction: column; gap: 0.25rem; }
@@ -0,0 +1,157 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule, GridDataResult, PageChangeEvent } from '@progress/kendo-angular-grid';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { GivingApiService } from '../../services/giving-api.service';
import { GivingCategoryApiService } from '../../services/giving-category-api.service';
import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import {
GivingListItemDto, GivingCategoryDto, CreateGivingRequest, PaymentMethod, PagedResult,
} from '../../models/giving.model';
/** Flattened member item with a single displayName field for the dropdown. */
interface MemberOption {
id: number;
displayName: string;
}
@Component({
selector: 'app-givings-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
DropDownsModule, DialogsModule, DateInputsModule,
],
templateUrl: './givings-page.component.html',
styleUrls: ['./givings-page.component.scss'],
})
export class GivingsPageComponent implements OnInit {
gridData: GridDataResult = { data: [], total: 0 };
page = 1;
pageSize = 20;
isLoading = false;
search = '';
filterCategoryId: number | null = null;
categories: GivingCategoryDto[] = [];
readonly paymentMethods: PaymentMethod[] = ['Cash', 'Check', 'Zelle', 'PayPal', 'Other'];
memberResults: MemberOption[] = [];
showDialog = false;
editingId: number | null = null;
form: CreateGivingRequest = this.blankForm();
selectedMemberId: number | null = null;
/** Separate Date field for kendo-datepicker (which binds Date, not string). */
givingDateValue: Date = new Date();
constructor(
private api: GivingApiService,
private categoryApi: GivingCategoryApiService,
private memberApi: MemberApiService,
) {}
ngOnInit(): void {
this.categoryApi.getAll(false).subscribe(c => this.categories = c);
this.load();
}
load(): void {
this.isLoading = true;
this.api.getPaged({
page: this.page, pageSize: this.pageSize,
search: this.search || undefined,
categoryId: this.filterCategoryId ?? undefined,
}).subscribe({
next: (r: PagedResult<GivingListItemDto>) => {
this.gridData = { data: r.items, total: r.totalCount }; this.isLoading = false;
},
error: () => { this.isLoading = false; },
});
}
onPageChange(e: PageChangeEvent): void {
this.page = e.skip / this.pageSize + 1; this.pageSize = e.take; this.load();
}
onSearch(): void { this.page = 1; this.load(); }
onMemberFilter(term: string): void {
if (!term || term.length < 1) { this.memberResults = []; return; }
this.memberApi.getPaged({ search: term, pageSize: 10 })
.subscribe(r => {
this.memberResults = r.items.map((m: MemberListItemDto) => ({
id: m.id,
displayName: memberDisplayName(m),
}));
});
}
openAdd(): void {
this.editingId = null;
this.form = this.blankForm();
this.selectedMemberId = null;
this.givingDateValue = new Date();
this.memberResults = [];
this.showDialog = true;
}
/** Called from template valueChange — receives the primitive id (number | null). */
onMemberIdSelected(id: number | null): void {
this.selectedMemberId = id ?? null;
this.form.memberId = this.selectedMemberId;
}
toggleAnonymous(): void {
this.form.isAnonymous = !this.form.isAnonymous;
if (this.form.isAnonymous) {
this.form.memberId = null;
this.selectedMemberId = null;
}
}
save(): void {
// Sync the datepicker Date back to the ISO string field.
this.form.givingDate = this.givingDateValue
? this.givingDateValue.toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
if (this.editingId) {
this.api.update(this.editingId, this.form).subscribe(() => { this.showDialog = false; this.load(); });
} else {
this.api.create(this.form).subscribe(() => { this.showDialog = false; this.load(); });
}
}
delete(g: GivingListItemDto): void {
if (!confirm('Delete this giving record?')) return;
this.api.delete(g.id).subscribe({
next: () => this.load(),
error: (err: { error?: { message?: string } }) =>
alert(err?.error?.message ?? 'Delete failed (record may belong to a locked session).'),
});
}
private blankForm(): CreateGivingRequest {
return {
memberId: null,
givingCategoryId: this.categories[0]?.id ?? 0,
amount: 0,
paymentMethod: 'Cash',
checkNumber: null,
zelleReferenceCode: null,
payPalTransactionId: null,
givingDate: new Date().toISOString().slice(0, 10),
isAnonymous: false,
notes: null,
};
}
}
@@ -0,0 +1,92 @@
<div class="page">
<header class="page-header">
<h2>Sunday Offering Entry / 主日奉獻錄入</h2>
<label>Date
<kendo-datepicker [(ngModel)]="sessionDate" (valueChange)="checkDate()" [disabled]="editingSessionId != null"></kendo-datepicker>
</label>
</header>
<div *ngIf="editingSessionId != null" class="edit-banner">
Editing submitted session — make changes and click "Update Session".
<button kendoButton fillMode="flat" (click)="cancelEdit()">Cancel edit</button>
</div>
<div *ngIf="dateConflict && editingSessionId == null" class="warn">
An offering session for this date already exists. Pick another date, or reopen the existing session to edit.
</div>
<section class="entry-row">
<label *ngIf="!entry.isAnonymous">Giver
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id"
[valuePrimitive]="true" [filterable]="true"
(filterChange)="onMemberFilter($event)" [(ngModel)]="selectedMemberId"
(valueChange)="onMemberSelected($event)" placeholder="Search by name"></kendo-dropdownlist>
</label>
<span *ngIf="entry.isAnonymous" class="anon-chip">Anonymous</span>
<label>Type
<kendo-dropdownlist [data]="categories" textField="name_en" valueField="id"
[valuePrimitive]="true" [(ngModel)]="entry.givingCategoryId"></kendo-dropdownlist>
</label>
<label>Method
<kendo-dropdownlist [data]="paymentMethods" [(ngModel)]="entry.paymentMethod"></kendo-dropdownlist>
</label>
<label *ngIf="entry.paymentMethod === 'Check'">Check #<kendo-textbox [(ngModel)]="entry.checkNumber"></kendo-textbox></label>
<label>Amount
<kendo-numerictextbox [(ngModel)]="entry.amount" [min]="0" [format]="'c2'" (keydown.enter)="addLine()"></kendo-numerictextbox>
</label>
<label>Notes<kendo-textbox [(ngModel)]="entry.notes" (keydown.enter)="addLine()"></kendo-textbox></label>
<div class="entry-actions">
<button kendoButton (click)="markAnonymous()">Anonymous</button>
<button kendoButton (click)="showQuickAdd = true">+ Quick add member</button>
<button kendoButton themeColor="primary" (click)="addLine()">+ Add (Enter)</button>
</div>
</section>
<kendo-grid [data]="buffer">
<kendo-grid-column title="Giver">
<ng-template kendoGridCellTemplate let-l>{{ l.isAnonymous ? '(Anonymous)' : l.memberName }}</ng-template>
</kendo-grid-column>
<kendo-grid-column field="categoryName" title="Type"></kendo-grid-column>
<kendo-grid-column field="paymentMethod" title="Method" [width]="90"></kendo-grid-column>
<kendo-grid-column field="checkNumber" title="Check #" [width]="90"></kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column title="" [width]="120">
<ng-template kendoGridCellTemplate let-l let-i="rowIndex">
<button kendoButton fillMode="flat" (click)="editLine(i)">Edit</button>
<button kendoButton fillMode="flat" (click)="removeLine(i)">×</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<section class="reconcile">
<div>Lines: {{ buffer.length }} | System total: {{ systemTotal | currency }}</div>
<label>Cash counted<kendo-numerictextbox [(ngModel)]="cashTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox></label>
<label>Check counted<kendo-numerictextbox [(ngModel)]="checkTotal" [min]="0" [format]="'c2'"></kendo-numerictextbox></label>
<div [class.ok]="difference === 0" [class.bad]="difference !== 0">Difference: {{ difference | currency }}</div>
<button kendoButton themeColor="primary"
[disabled]="buffer.length === 0 || (editingSessionId == null && dateConflict) || submitting"
(click)="submit()">{{ editingSessionId != null ? 'Update Session' : 'Submit' }}</button>
</section>
<section class="sessions-list">
<h3>Recent Sessions</h3>
<kendo-grid [data]="sessions">
<kendo-grid-column field="sessionDate" title="Date" [width]="120"></kendo-grid-column>
<kendo-grid-column field="status" title="Status" [width]="110"></kendo-grid-column>
<kendo-grid-column field="lineCount" title="Lines" [width]="80"></kendo-grid-column>
<kendo-grid-column field="systemTotal" title="System" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column field="difference" title="Diff" [width]="100" format="c2"></kendo-grid-column>
<kendo-grid-column title="" [width]="140">
<ng-template kendoGridCellTemplate let-s>
<button kendoButton fillMode="flat" *ngIf="s.status === 'Submitted'" (click)="reopenAndEdit(s)">Reopen &amp; Edit</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
</section>
<app-member-quick-add-dialog *ngIf="showQuickAdd"
(created)="onMemberQuickCreated($event)"
(cancelled)="showQuickAdd = false"></app-member-quick-add-dialog>
</div>
@@ -0,0 +1,11 @@
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.warn { background: #fff3cd; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; }
.entry-row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; margin-bottom: 1rem; }
.entry-row label { display: flex; flex-direction: column; gap: 0.25rem; }
.entry-actions { display: flex; gap: 0.5rem; }
.anon-chip { padding: 0.25rem 0.5rem; background: #eee; border-radius: 4px; }
.reconcile { display: flex; gap: 1rem; align-items: flex-end; margin-top: 1rem; }
.reconcile .ok { color: green; font-weight: 600; }
.reconcile .bad { color: #c00; font-weight: 600; }
.edit-banner { display: flex; align-items: center; gap: 1rem; background: #fff3cd; border-left: 4px solid #f0a500; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-weight: 500; }
.sessions-list { margin-top: 2rem; }
@@ -0,0 +1,225 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Observable } from 'rxjs';
import { GridModule } from '@progress/kendo-angular-grid';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { OfferingSessionApiService } from '../../services/offering-session-api.service';
import { GivingCategoryApiService } from '../../services/giving-category-api.service';
import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import { MemberQuickAddDialogComponent } from '../../components/member-quick-add-dialog/member-quick-add-dialog.component';
import {
GivingCategoryDto, PaymentMethod, OfferingBufferLine, CreateOfferingSessionRequest,
OfferingSessionListItemDto, OfferingSessionDto,
} from '../../models/giving.model';
interface MemberOption { id: number; displayName: string; }
@Component({
selector: 'app-offering-session-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
DropDownsModule, DateInputsModule, MemberQuickAddDialogComponent,
],
templateUrl: './offering-session-page.component.html',
styleUrls: ['./offering-session-page.component.scss'],
})
export class OfferingSessionPageComponent implements OnInit {
sessionDate: Date = new Date();
dateConflict = false;
categories: GivingCategoryDto[] = [];
readonly paymentMethods: PaymentMethod[] = ['Cash', 'Check', 'Zelle', 'PayPal', 'Other'];
memberResults: MemberOption[] = [];
selectedMemberId: number | null = null;
selectedMemberName: string | null = null;
entry = this.blankEntry();
buffer: OfferingBufferLine[] = [];
editingIndex: number | null = null;
cashTotal = 0;
checkTotal = 0;
notes: string | null = null;
showQuickAdd = false;
submitting = false;
sessions: OfferingSessionListItemDto[] = [];
editingSessionId: number | null = null;
constructor(
private api: OfferingSessionApiService,
private categoryApi: GivingCategoryApiService,
private memberApi: MemberApiService,
) {}
ngOnInit(): void {
this.categoryApi.getAll(false).subscribe(c => {
this.categories = c;
this.entry.givingCategoryId = c[0]?.id ?? 0;
});
this.checkDate();
this.loadSessions();
}
get systemTotal(): number { return this.buffer.reduce((s, l) => s + (l.amount || 0), 0); }
get difference(): number { return (this.cashTotal + this.checkTotal) - this.systemTotal; }
checkDate(): void {
this.api.checkDate(this.toIso(this.sessionDate)).subscribe(r => this.dateConflict = r.exists);
}
loadSessions(): void {
this.api.getPaged(1, 20).subscribe(r => this.sessions = r.items);
}
reopenAndEdit(s: OfferingSessionListItemDto): void {
if (s.status !== 'Submitted') return;
this.api.reopen(s.id).subscribe({
next: () => this.api.getById(s.id).subscribe(dto => this.loadIntoBuffer(dto)),
error: (err: { error?: { message?: string } }) => alert(err?.error?.message ?? 'Reopen failed.'),
});
}
private loadIntoBuffer(dto: OfferingSessionDto): void {
this.editingSessionId = dto.id;
this.sessionDate = new Date(dto.sessionDate + 'T00:00:00');
this.dateConflict = false;
this.cashTotal = dto.cashTotal;
this.checkTotal = dto.checkTotal;
this.notes = dto.notes;
this.buffer = dto.givings.map(g => ({
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,
}));
this.resetEntry();
}
cancelEdit(): void {
this.editingSessionId = null;
this.editingIndex = null;
this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
this.sessionDate = new Date();
this.checkDate();
}
onMemberFilter(term: string): void {
if (!term) { this.memberResults = []; return; }
this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe(r =>
this.memberResults = r.items.map((m: MemberListItemDto) => ({ id: m.id, displayName: memberDisplayName(m) })));
}
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;
}
addLine(): void {
if (this.entry.amount <= 0) return;
if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return;
const cat = this.categories.find(c => c.id === this.entry.givingCategoryId);
const line: OfferingBufferLine = {
...this.entry,
memberName: this.entry.isAnonymous ? null : this.selectedMemberName,
categoryName: cat?.name_en ?? '',
};
if (this.editingIndex !== null) { this.buffer[this.editingIndex] = line; this.editingIndex = null; }
else { this.buffer = [...this.buffer, line]; }
this.resetEntry();
}
editLine(i: number): void {
const l = this.buffer[i];
this.entry = { ...l };
this.selectedMemberId = l.memberId;
this.selectedMemberName = l.memberName;
// Seed the dropdown so the giver name stays visible while editing the line.
this.memberResults = (l.memberId && l.memberName)
? [{ id: l.memberId, displayName: l.memberName }]
: [];
this.editingIndex = i;
}
removeLine(i: number): void { this.buffer = this.buffer.filter((_, idx) => idx !== i); }
onMemberQuickCreated(m: MemberListItemDto): void {
this.showQuickAdd = false;
const opt: MemberOption = { id: m.id, displayName: memberDisplayName(m) };
this.memberResults = [opt, ...this.memberResults.filter(x => x.id !== m.id)];
this.onMemberSelected(m.id);
}
submit(): void {
if (this.buffer.length === 0 || (this.editingSessionId == null && this.dateConflict)) return;
this.submitting = true;
const req: CreateOfferingSessionRequest = {
sessionDate: this.toIso(this.sessionDate),
cashTotal: this.cashTotal,
checkTotal: this.checkTotal,
notes: this.notes,
givings: this.buffer.map(l => ({
memberId: l.memberId,
givingCategoryId: l.givingCategoryId,
amount: l.amount,
paymentMethod: l.paymentMethod,
checkNumber: l.checkNumber,
zelleReferenceCode: l.zelleReferenceCode,
payPalTransactionId: l.payPalTransactionId,
isAnonymous: l.isAnonymous,
notes: l.notes,
})),
};
const obs: Observable<unknown> = this.editingSessionId != null
? this.api.replace(this.editingSessionId, req)
: this.api.create(req);
obs.subscribe({
next: () => {
this.submitting = false;
alert(this.editingSessionId != null ? 'Offering session updated.' : 'Offering session submitted.');
this.editingSessionId = null;
this.editingIndex = null;
this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null;
this.sessionDate = new Date();
this.checkDate();
this.loadSessions();
},
error: (err: { error?: { message?: string } }) => {
this.submitting = false;
alert(err?.error?.message ?? 'Submit failed.');
},
});
}
private resetEntry(): void {
this.editingIndex = null;
this.selectedMemberId = null; this.selectedMemberName = null; this.memberResults = [];
this.entry = this.blankEntry();
this.entry.givingCategoryId = this.categories[0]?.id ?? 0;
}
private blankEntry(): OfferingBufferLine {
return {
memberId: null, givingCategoryId: 0, amount: 0, paymentMethod: 'Cash',
checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null,
isAnonymous: false, notes: null, memberName: null, categoryName: '',
};
}
private toIso(d: Date): string { return d.toISOString().slice(0, 10); }
}
@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
GivingListItemDto, CreateGivingRequest, UpdateGivingRequest, PagedResult,
} from '../models/giving.model';
export interface GivingQuery {
page?: number; pageSize?: number; search?: string;
categoryId?: number; from?: string; to?: string;
}
@Injectable({ providedIn: 'root' })
export class GivingApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('givings');
}
getPaged(q: GivingQuery = {}): Observable<PagedResult<GivingListItemDto>> {
let p = new HttpParams().set('page', q.page ?? 1).set('pageSize', q.pageSize ?? 20);
if (q.search) p = p.set('search', q.search);
if (q.categoryId != null) p = p.set('categoryId', q.categoryId);
if (q.from) p = p.set('from', q.from);
if (q.to) p = p.set('to', q.to);
return this.http.get<PagedResult<GivingListItemDto>>(this.endpoint, { params: p });
}
create(request: CreateGivingRequest): Observable<{ id: number }> {
return this.http.post<{ id: number }>(this.endpoint, request);
}
update(id: number, request: UpdateGivingRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/${id}`, request);
}
delete(id: number): Observable<void> {
return this.http.delete<void>(`${this.endpoint}/${id}`);
}
}
@@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
GivingCategoryDto, CreateGivingCategoryRequest, UpdateGivingCategoryRequest,
} from '../models/giving.model';
@Injectable({ providedIn: 'root' })
export class GivingCategoryApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('giving-categories');
}
getAll(includeInactive = false): Observable<GivingCategoryDto[]> {
const params = new HttpParams().set('includeInactive', includeInactive);
return this.http.get<GivingCategoryDto[]>(this.endpoint, { params });
}
create(request: CreateGivingCategoryRequest): Observable<{ id: number }> {
return this.http.post<{ id: number }>(this.endpoint, request);
}
update(id: number, request: UpdateGivingCategoryRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/${id}`, request);
}
deactivate(id: number): Observable<void> {
return this.http.delete<void>(`${this.endpoint}/${id}`);
}
}
@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
OfferingSessionDto, OfferingSessionListItemDto,
CreateOfferingSessionRequest, PagedResult,
} from '../models/giving.model';
@Injectable({ providedIn: 'root' })
export class OfferingSessionApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('offering-sessions');
}
getPaged(page = 1, pageSize = 20): Observable<PagedResult<OfferingSessionListItemDto>> {
const params = new HttpParams().set('page', page).set('pageSize', pageSize);
return this.http.get<PagedResult<OfferingSessionListItemDto>>(this.endpoint, { params });
}
getById(id: number): Observable<OfferingSessionDto> {
return this.http.get<OfferingSessionDto>(`${this.endpoint}/${id}`);
}
checkDate(date: string): Observable<{ exists: boolean }> {
const params = new HttpParams().set('date', date);
return this.http.get<{ exists: boolean }>(`${this.endpoint}/check-date`, { params });
}
create(request: CreateOfferingSessionRequest): Observable<{ id: number }> {
return this.http.post<{ id: number }>(this.endpoint, request);
}
reopen(id: number): Observable<void> {
return this.http.post<void>(`${this.endpoint}/${id}/reopen`, {});
}
replace(id: number, request: CreateOfferingSessionRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/${id}`, request);
}
}
@@ -51,6 +51,15 @@
</button> </button>
</ng-container> </ng-container>
</div> </div>
<div class="nav-section" *ngIf="showFinanceSection">
<h4>Finance</h4>
<button *ngFor="let item of financeNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }}
</button>
</div>
</nav> </nav>
</div> </div>
</kendo-drawer-content> </kendo-drawer-content>
@@ -68,8 +68,15 @@ export class UserNavbarComponent implements OnInit, OnDestroy {
{ text: 'User Management', icon: userIcon, path: '/user-portal/admin/users' }, { text: 'User Management', icon: userIcon, path: '/user-portal/admin/users' },
]; ];
public financeNavItems: NavItem[] = [
{ text: 'Offering Entry', icon: this.creditCardIcon, path: '/user-portal/finance/offering-session' },
{ text: 'Givings', icon: this.chartIcon, path: '/user-portal/finance/givings' },
{ text: 'Giving Types', icon: this.buildingIcon, path: '/user-portal/finance/giving-categories' },
];
public showMemberAdminSection = false; public showMemberAdminSection = false;
public showUserAdminSection = false; public showUserAdminSection = false;
public showFinanceSection = false;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -97,6 +104,7 @@ export class UserNavbarComponent implements OnInit, OnDestroy {
const roles = user?.roles ?? []; const roles = user?.roles ?? [];
this.showMemberAdminSection = roles.some(r => r === 'super_admin' || r === 'secretary'); this.showMemberAdminSection = roles.some(r => r === 'super_admin' || r === 'secretary');
this.showUserAdminSection = roles.includes('super_admin'); this.showUserAdminSection = roles.includes('super_admin');
this.showFinanceSection = roles.some(r => r === 'finance' || r === 'super_admin');
}); });
} }
@@ -113,12 +121,12 @@ export class UserNavbarComponent implements OnInit, OnDestroy {
private updateActiveStates(currentUrl: string): void { private updateActiveStates(currentUrl: string): void {
// Reset all active states // Reset all active states
[...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems,
...this.memberAdminNavItems, ...this.userAdminNavItems] ...this.memberAdminNavItems, ...this.userAdminNavItems, ...this.financeNavItems]
.forEach(item => item.active = false); .forEach(item => item.active = false);
// Set active state for current route // Set active state for current route
const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems,
...this.memberAdminNavItems, ...this.userAdminNavItems] ...this.memberAdminNavItems, ...this.userAdminNavItems, ...this.financeNavItems]
.find(item => currentUrl.startsWith(item.path)); .find(item => currentUrl.startsWith(item.path));
if (activeItem) { if (activeItem) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,298 @@
# Design Spec: 奉獻追蹤(手動)Giving / Donation Tracking
**日期:** 2026-05-28
**作者:** Chris Chen
**狀態:** 待核准
**對應規劃:** `docs/PLANNING.md §3.6 / §3.6c``docs/DB_SCHEMA.md §7`
---
## 1. 範疇
本 spec 只規劃**奉獻追蹤(手動)**模組,三項核心功能:
1. **奉獻類型設定**`GivingCategory` CRUD(什一 / 一般 / 特別 / 建堂 / 宣教…)
2. **單筆奉獻記錄** — 單筆 `Giving`(現金 / 支票 / Zelle / PayPal 手動)
3. **主日奉獻袋批次輸入**`OfferingSession` 鍵盤優先快速錄入 + 對帳
**明確不在本次範圍(之後另開 spec):**
- 支出追蹤 & 報銷、月結對帳表(屬另一模組)
- 年度 IRS 收據(`GivingReceipt`PLANNING §3.7
- 個人奉獻歷史查詢、奉獻摘要報表(PLANNING §3.9
- 線上金流 Stripe / PayPal Checkout、定期奉獻(`GivingRecurringSchedule`Phase 4
- 收據照片上傳(奉獻無此需求;blob 儲存尚未建置)
---
## 2. 關鍵決策摘要
| # | 決策 | 選擇 |
|---|------|------|
| D1 | 批次錄入儲存策略 | **B — 純前端緩衝、最後一次 bulk submit**。前端記憶體累積整批,提交時一次 `POST` 建立 Session + 所有 Giving(單一 transaction)。 |
| D2 | Session 鎖定 | **Submitted 後鎖定**。修改需 `finance` 角色 `reopen`(回 Draft、整批載回前端)→ 編輯 → 重新提交整批替換。所有變動進 Audit Log。 |
| D3 | 奇款人識別 | giver 永遠是 **Member 或匿名**(維持 DB_SCHEMA 不變,不加 GiverName 自由欄)。非匿名又查無此人時,批次介面可**快速新增 Member**。 |
| D4 | 快速新增教友欄位 | 快速表單**要求英文姓名**(`FirstName_en` / `LastName_en`),與現有 `Member` schema 必填一致;`Status` 預設 `Visitor`。 |
| D5 | 授權 | 奉獻所有寫入操作限 `finance` + `super_admin`(對應 PLANNING §4 權限矩陣)。 |
---
## 3. 資料模型
`DB_SCHEMA.md §7`,新增 3 個 entity,全部繼承 `AuditableEntity`(非軟刪除 —— 奉獻記錄為金錢帳目,刪除走實刪 + Audit Log;與 `Member` 的軟刪除不同)。
> **討論點(待確認):** DB_SCHEMA §7 的 Giving / OfferingSession 未列 `IsDeleted`。本 spec 採實刪 + Audit。若日後需保留刪除痕跡,可改繼承 `SoftDeleteEntity`,但會影響月結加總邏輯(需排除 `IsDeleted`)。本次維持實刪。
### 3.1 GivingCategory(奉獻類型)
| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| Name_en | varchar(200) NOT NULL | e.g. 'Tithe' |
| Name_zh | varchar(200)? | e.g. '什一奉獻' |
| Description_en | varchar(500)? | |
| Description_zh | varchar(500)? | |
| IsActive | bool NOT NULL DEFAULT true | DELETE = 軟停用(`IsActive=false`),不實刪,以保留歷史 Giving 的 FK |
| SortOrder | int NOT NULL DEFAULT 0 | |
| + AuditableEntity 欄位 | | CreatedAt/By, UpdatedAt/By |
### 3.2 OfferingSession(主日奉獻袋批次)
| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| SessionDate | date NOT NULL **UNIQUE** | 一個主日只有一個 Session |
| Status | varchar(20) NOT NULL DEFAULT 'Draft' | 'Draft' \| 'Submitted' \| 'Reconciled' |
| CashTotal | decimal(18,2) NOT NULL DEFAULT 0 | 財務人工清點現金總額 |
| CheckTotal | decimal(18,2) NOT NULL DEFAULT 0 | 支票清點總額 |
| SystemTotal | decimal(18,2) NOT NULL DEFAULT 0 | **伺服器**計算的明細加總 |
| Difference | decimal(18,2) NOT NULL DEFAULT 0 | = (CashTotal + CheckTotal) SystemTotal,目標 0 |
| Notes | text? | |
| SubmittedAt / SubmittedBy | timestamp? / varchar(450)? | |
| ReconciledAt / ReconciledBy | timestamp? / varchar(450)? | |
| + AuditableEntity 欄位 | | |
> **Reconciled 狀態用途:** 本次仍實作 `submit`Draft→Submitted)。`Reconciled` 作為日後月結對帳模組對接的終態保留欄位,本次 UI 不主動設定(只在 schema 預留)。
### 3.3 Giving(奉獻記錄)
| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| MemberId | int? | FK → Members.Id;null = 匿名 |
| GivingCategoryId | int NOT NULL | FK → GivingCategories.Id |
| OfferingSessionId | int? | FK → OfferingSessions.Id;null = 非批次單筆 |
| Amount | decimal(18,2) NOT NULL | IRS 收據用金額 |
| PaymentMethod | varchar(20) NOT NULL | 'Cash' \| 'Check' \| 'Zelle' \| 'PayPal' \| 'Other' |
| CheckNumber | varchar(50)? | PaymentMethod=Check 時必填 |
| ZelleReferenceCode | varchar(100)? | PaymentMethod=Zelle |
| PayPalTransactionId | varchar(100)? | PaymentMethod=PayPal |
| GivingDate | date NOT NULL | 批次時 = SessionDate |
| IsAnonymous | bool NOT NULL DEFAULT false | |
| Notes | varchar(500)? | |
| + AuditableEntity 欄位 | | |
> Phase 4 欄位(`GrossAmount` / `FeeAmount` / `StripePaymentIntentId` / `GivingRecurringScheduleId`)本次**不建**,待線上金流模組再加,屆時為純新增欄位,不影響本次結構。
### 3.4 EF Core 設定(`AppDbContext.OnModelCreating`
- 新增 3 個 `DbSet<GivingCategory>` / `<OfferingSession>` / `<Giving>`
- fluent 設定:各 `Name_*` / `Description_*` maxlength;decimal 欄位 `decimal(18,2)`(沿用既有全域 decimal 慣例,或逐欄設定)。
- `OfferingSession`:`HasIndex(SessionDate).IsUnique()`
- `Giving` 索引(對應 DB_SCHEMA §17):
- `idx_givings_member_date``(MemberId, GivingDate)`
- `idx_givings_session``(OfferingSessionId)` filter not null
- `idx_givings_date``(GivingDate)`
- FK 行為:
- `Giving.GivingCategoryId``Restrict`(類型有奉獻時不可實刪,只能軟停用)
- `Giving.MemberId``SetNull`
- `Giving.OfferingSessionId``Cascade`(刪 Session 連帶刪明細;一般不會發生,僅資料一致性)
### 3.5 Seed`DbSeeder`
新增 `SeedGivingCategoriesAsync`(沿用既有 code-based 風格,非 `HasData`):
```
1. Tithe / 什一奉獻
2. General Offering / 一般奉獻
3. Special Offering / 特別奉獻
4. Building Fund / 建堂基金
5. Mission / 宣教奉獻
```
### 3.6 Migration
一支 EF migration(如 `AddGivingModule`),涵蓋 3 表 + 索引 + FK。
---
## 4. API 介面
全部 `[Authorize(Roles = "finance,super_admin")]`。Controller 維持 thin → 委派 service,沿用既有 `PagedResult<>` 與錯誤轉換慣例(`KeyNotFoundException`→404,鎖定衝突→409)。
### 4.1 奉獻類型 `GivingCategoriesController` — `/api/giving-categories`
```
GET /api/giving-categories?includeInactive=false 全部(預設僅 active
POST /api/giving-categories
PUT /api/giving-categories/{id}
DELETE /api/giving-categories/{id} 軟停用 IsActive=false
```
### 4.2 單筆奉獻 `GivingsController` — `/api/givings`
```
GET /api/givings?page&pageSize&search&categoryId&from&to 分頁列表
GET /api/givings/{id}
POST /api/givings 單筆,OfferingSessionId=null
PUT /api/givings/{id}
DELETE /api/givings/{id}
```
- `search` 比對 Member 姓名(中英)/ CheckNumber / Notes。
- 屬於某 Session`OfferingSessionId != null`)的 Giving,若該 Session 已 `Submitted`,單筆 PUT/DELETE 一律拒絕(409)—— 必須走 Session `reopen`
### 4.3 批次 `OfferingSessionsController` — `/api/offering-sessions`
```
GET /api/offering-sessions?page&pageSize&from&to 歷次 Session 列表
GET /api/offering-sessions/{id} 含完整明細 + 小計(reopen/檢視用)
POST /api/offering-sessions 一次建立整批(見 §6)
POST /api/offering-sessions/{id}/reopen Submitted→Draftfinance,進 Audit
PUT /api/offering-sessions/{id} 整批替換明細 + 總額,重新 Submitted
GET /api/offering-sessions/check-date?date=... 檢查該日期是否已有 Session
```
- 批次教友搜尋:**復用** `GET /api/members?search=`(已支援中英文,見 `MemberService`)。
- 批次內快速新增教友:**復用** `POST /api/members`(現有 `CreateMemberRequest`)。
#### POST /api/offering-sessions 請求形狀
```jsonc
{
"sessionDate": "2026-05-31",
"cashTotal": 1250.00,
"checkTotal": 800.00,
"notes": "...",
"givings": [
{ "memberId": 12, "givingCategoryId": 1, "amount": 100.00,
"paymentMethod": "Cash", "isAnonymous": false, "notes": null },
{ "memberId": null, "givingCategoryId": 2, "amount": 50.00,
"paymentMethod": "Cash", "isAnonymous": true },
{ "memberId": 8, "givingCategoryId": 1, "amount": 300.00,
"paymentMethod": "Check", "checkNumber": "1043" }
]
}
```
回傳建立後的 Session(含 `id`、伺服器算的 `systemTotal``difference``status="Submitted"`)。
---
## 5. 後端 Service 層
3 個 serviceinterface + impl,沿用 `MemberService` 風格:建構式注入 `AppDbContext` + `IHttpContextAccessor``CurrentUserId`、手動 DTO mapping)。
- **`IGivingCategoryService`** — CRUD;DELETE = 設 `IsActive=false`
- **`IGivingService`** — 單筆 CRUD + 分頁查詢;寫入前檢查所屬 Session 鎖定狀態。
- **`IOfferingSessionService`** — 核心:
- `CreateAsync(request)`:單一 transaction 內建立 `OfferingSession`Status=Submitted+ 所有 `Giving`(`OfferingSessionId` 回填、`GivingDate=SessionDate`);**伺服器重算** `SystemTotal = Σ amount``Difference`;`SubmittedAt/By` 設值。`SessionDate` 撞 unique → 409。
- `GetByIdAsync(id)`:含明細投影。
- `ReopenAsync(id)`:僅 `Submitted` 可 reopen → `Draft`,清 `SubmittedAt/By`
- `ReplaceAsync(id, request)`:僅 `Draft` 可改;刪舊明細、插新明細、重算、回 `Submitted`
- **鎖定守門**:對已 `Submitted` 的修改丟 `InvalidOperationException` → controller 轉 409。
- 金額一律以伺服器加總為準(不信任前端 `systemTotal`)。
---
## 6. 批次錄入流程(前端,核心體驗)
頁面:`features/giving/pages/offering-session-page`。採決策 D1(純前端緩衝)。
```
1. 選日期(預設今天)→ 呼叫 check-date 確認當日尚無 Session
2. 逐筆快速錄入(全部存在前端記憶體 buffer)
┌─────────────────────────────────────────────┐
│ 搜尋教友:[即時下拉] ← 復用 GET /api/members │
│ 類型:[GivingCategory ▼] │
│ 付款:(●現金 ○支票 ○Zelle ○PayPal) │
│ 現金→信封號(選填) / 支票→支票號(必填) │
│ 金額:[$____] 備註:[____] │
│ [匿名] [快速新增教友] [+ 新增(Enter)] │
│ ───────────────────────────────────────── │
│ 已錄入 N 筆 | 前端小計:$X,XXX.XX │
└─────────────────────────────────────────────┘
- Tab 跳欄、Enter 新增;新增後焦點回搜尋框
- 樂觀 UI:新增即在右側清單顯示、即時更新前端小計
- 清單每列可即時改 / 刪(純前端操作)
3. 對帳步驟:輸入實點 CashTotal / CheckTotal
→ 前端顯示 Difference = (Cash+Check) 前端小計(目標 0
4. 「提交」→ 一次 POST /api/offering-sessions(整批)
→ 伺服器 transaction 建立 + 重算 + 回傳 → 鎖定
5. 編輯已提交:finance 在歷次列表點 reopen
→ GET 載回整批進 buffer → 編輯 → PUT 整批替換 → 重新 Submitted
```
**鍵盤優先要點:** 復用既有 `focus-navigator` / `init-focus` directives 與 `currency-input` / `drop-down-list` 共用元件。
---
## 7. 快速新增教友(批次介面內)
- 搜尋無結果時出現「快速新增教友」→ inline 小 dialog。
- 收最少欄位:**`FirstName_en``LastName_en`(必填,決策 D4**、`FirstName_zh`/`LastName_zh`(選填)、`PhoneCell`(選填)。
- `Status` 預設 `Visitor`
- 呼叫現有 `POST /api/members``CreateMemberRequest`)→ 取回 `id` → 自動帶回當前錄入列、焦點移至金額。
---
## 8. 前端 — 其餘頁面
依既有 feature 結構 `features/giving/{pages,components,services,models}`,標準元件:standalone component + Kendo Grid/Inputs/Buttons/Dropdowns + RxJS + `ApiConfigService`
### 8.1 奉獻類型管理頁 `giving-categories-page`
Kendo Grid 列表 + 新增/編輯 dialog(雙欄 EN/中名稱、排序、啟用)。沿用 users/members 頁模式。
### 8.2 單筆奉獻頁 `givings-page`
Kendo Grid 列表(搜尋 / 日期區間 / 類型篩選 / 分頁)+ 單筆新增/編輯 dialog。付款方式切換時動態顯示支票號 / Zelle ref / PayPal txn 欄位。教友選擇復用 `GET /api/members?search=`
### 8.3 API service / model
每頁一支 `*-api.service.ts` + `models/*.model.ts`DTO interface,對齊後端 DTO 命名)。
---
## 9. 授權 & 導覽
- 後端:所有奉獻 controller `[Authorize(Roles="finance,super_admin")]`
- 前端:沿用既有 role-gated sidebar nav(commit `bc67146` 的 Administration 機制),在 Finance/Administration 區塊新增「奉獻」分組,內含三頁,僅 `finance` / `super_admin` 可見。
- route guard 沿用 `core/guards` 既有角色守衛。
---
## 10. 測試
後端 `ROLAC.API.Tests`(沿用既有測試風格,InMemory provider):
- `GivingCategoryService`:CRUD、DELETE 軟停用、有奉獻時類型不可實刪。
- `GivingService`:單筆 CRUD、分頁/篩選、屬已 Submitted Session 的單筆寫入被拒(409)。
- `OfferingSessionService`(重點):
- 整批建立 → `SystemTotal` / `Difference` 伺服器重算正確(不信前端值)。
- `SessionDate` 撞 unique → 409。
-`Submitted` 修改被拒(鎖定守門)。
- `reopen``replace` → 重新 `Submitted` 流程,明細正確替換。
- 匿名筆(`MemberId=null` / `IsAnonymous=true`)正確記錄。
- 授權:非 finance/super_admin 角色被 403。
---
## 11. 風險 / 待確認
| # | 項目 | 說明 |
|---|------|------|
| R1 | 實刪 vs 軟刪除(§3 討論點) | 本次 Giving/Session 採實刪 + Audit。若財務合規要求保留刪除痕跡,需改軟刪除並調整加總邏輯。**待 Chris 確認。** |
| R2 | 前端緩衝資料遺失(決策 B 的取捨) | 提交前瀏覽器崩潰 → 整批未存的資料遺失。可選緩解:`localStorage` 暫存草稿(本次列為可選,不強制)。 |
| R3 | reopen 整批替換的 Audit 粒度 | 整批替換會產生「刪舊 N 筆 + 插新 M 筆」的 Audit 記錄,而非逐筆 diff。對帳追溯時需理解此模型。 |
| R4 | Audit Log 基礎設施 | 現有 `AuditSaveChangesInterceptor` 是否已涵蓋這些新 entity 的 Create/Update/Delete,需在實作時確認;`AuditLog` 表本身屬另一模組,若尚未建置需補。 |
---
## 12. 交付順序建議
1. Entities + EF 設定 + Seed + migration
2. Service 層(3 個)+ 單元測試
3. Controllers + 授權
4. 前端 models + api services
5. 奉獻類型管理頁 → 單筆奉獻頁 → 批次錄入頁
6. 導覽整合 + 端到端手動驗證