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:
@@ -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; }
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -11,6 +11,9 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<Member> Members => Set<Member>();
|
||||
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)
|
||||
{
|
||||
@@ -89,5 +92,55 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.HasOne(e => e.FamilyUnit).WithMany()
|
||||
.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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Data;
|
||||
|
||||
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 =
|
||||
[
|
||||
("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>
|
||||
/// Seeds roles and (in Development) the default admin account.
|
||||
/// Called once on application startup after migrations have been applied.
|
||||
@@ -49,6 +78,9 @@ public static class DbSeeder
|
||||
|
||||
await SeedRolesAsync(roleManager);
|
||||
|
||||
var db = services.GetRequiredService<AppDbContext>();
|
||||
await SeedGivingCategoriesAsync(db);
|
||||
|
||||
if (env.IsDevelopment())
|
||||
await SeedAdminUserAsync(userManager);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
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")
|
||||
@@ -428,6 +557,77 @@ namespace ROLAC.API.Migrations
|
||||
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")
|
||||
@@ -528,6 +728,31 @@ namespace ROLAC.API.Migrations
|
||||
.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")
|
||||
@@ -553,6 +778,11 @@ namespace ROLAC.API.Migrations
|
||||
{
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
|
||||
{
|
||||
b.Navigation("Givings");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,9 @@ builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IMemberService, MemberService>();
|
||||
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||||
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
|
||||
builder.Services.AddScoped<IGivingService, GivingService>();
|
||||
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import { AuthGuard } from './core/guards/auth.guard';
|
||||
import { RoleGuard } from './core/guards/role.guard';
|
||||
import { MembersPageComponent } from './features/members/pages/members-page/members-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 = [
|
||||
// Public routes
|
||||
@@ -26,6 +29,24 @@ export const routes: Routes = [
|
||||
canActivate: [RoleGuard],
|
||||
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'] },
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
+13
@@ -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>
|
||||
+76
@@ -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
|
||||
}
|
||||
+58
@@ -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>
|
||||
+31
@@ -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;
|
||||
}
|
||||
+74
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+92
@@ -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 & 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>
|
||||
+11
@@ -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; }
|
||||
+225
@@ -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>
|
||||
</ng-container>
|
||||
</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>
|
||||
</div>
|
||||
</kendo-drawer-content>
|
||||
|
||||
@@ -68,8 +68,15 @@ export class UserNavbarComponent implements OnInit, OnDestroy {
|
||||
{ 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 showUserAdminSection = false;
|
||||
public showFinanceSection = false;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -97,6 +104,7 @@ export class UserNavbarComponent implements OnInit, OnDestroy {
|
||||
const roles = user?.roles ?? [];
|
||||
this.showMemberAdminSection = roles.some(r => r === 'super_admin' || r === 'secretary');
|
||||
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 {
|
||||
// Reset all active states
|
||||
[...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems,
|
||||
...this.memberAdminNavItems, ...this.userAdminNavItems]
|
||||
...this.memberAdminNavItems, ...this.userAdminNavItems, ...this.financeNavItems]
|
||||
.forEach(item => item.active = false);
|
||||
|
||||
// Set active state for current route
|
||||
const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems,
|
||||
...this.memberAdminNavItems, ...this.userAdminNavItems]
|
||||
...this.memberAdminNavItems, ...this.userAdminNavItems, ...this.financeNavItems]
|
||||
.find(item => currentUrl.startsWith(item.path));
|
||||
|
||||
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→Draft(finance,進 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 個 service(interface + 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. 導覽整合 + 端到端手動驗證
|
||||
Reference in New Issue
Block a user