From 3558c67fd7971d2de2cf32d507c335ca8f8236b7 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Sat, 20 Jun 2026 17:51:33 -0700 Subject: [PATCH] WIP --- .../Services/AmountToWordsTests.cs | 29 ++ .../Services/DisbursementServiceTests.cs | 271 +++++++++++++ .../Services/ExpenseServiceTests.cs | 14 +- .../Controllers/ChurchProfileController.cs | 25 ++ .../Controllers/DisbursementsController.cs | 95 +++++ .../DTOs/Disbursement/ChurchProfileDtos.cs | 29 ++ .../DTOs/Disbursement/DisbursementDtos.cs | 107 ++++++ API/ROLAC.API/Data/AppDbContext.cs | 74 ++++ API/ROLAC.API/Data/DbSeeder.cs | 17 + API/ROLAC.API/Entities/Check.cs | 43 +++ API/ROLAC.API/Entities/CheckLine.cs | 18 + API/ROLAC.API/Entities/ChurchProfile.cs | 26 ++ .../Migrations/AppDbContextModelSnapshot.cs | 287 ++++++++++++++ API/ROLAC.API/Program.cs | 4 + API/ROLAC.API/ROLAC.API.csproj | 3 +- .../Services/ChurchProfileService.cs | 44 +++ .../Services/Disbursement/AmountToWords.cs | 75 ++++ .../Disbursement/CheckPrintService.cs | 245 ++++++++++++ .../Disbursement/ICheckPrintService.cs | 28 ++ API/ROLAC.API/Services/DisbursementService.cs | 363 ++++++++++++++++++ API/ROLAC.API/Services/ExpenseService.cs | 16 +- .../Services/IChurchProfileService.cs | 9 + .../Services/IDisbursementService.cs | 18 + APP/src/app/app.config.ts | 5 +- APP/src/app/app.html | 3 +- APP/src/app/app.routes.ts | 21 + APP/src/app/app.ts | 4 +- .../toast-container.component.scss | 64 +++ .../toast-container.component.ts | 27 ++ .../interceptors/http-error.interceptor.ts | 94 +++++ APP/src/app/core/services/toast.service.ts | 42 ++ .../issue-check-dialog.component.html | 75 ++++ .../issue-check-dialog.component.ts | 83 ++++ .../receipt-sign-dialog.component.html | 31 ++ .../receipt-sign-dialog.component.ts | 87 +++++ .../disbursement/models/disbursement.model.ts | 53 +++ .../check-register-page.component.html | 120 ++++++ .../check-register-page.component.scss | 26 ++ .../check-register-page.component.ts | 107 ++++++ .../church-profile-page.component.html | 53 +++ .../church-profile-page.component.ts | 39 ++ .../disbursement-page.component.html | 59 +++ .../disbursement-page.component.scss | 3 + .../disbursement-page.component.ts | 78 ++++ .../services/disbursement-api.service.ts | 78 ++++ .../expense-form-dialog.component.ts | 28 +- .../expense-categories-page.component.html | 24 +- .../expense-categories-page.component.scss | 15 + .../expense-categories-page.component.ts | 58 ++- .../expenses-page.component.html | 61 +-- .../expenses-page/expenses-page.component.ts | 8 +- .../my-reimbursements-page.component.html | 6 +- .../my-reimbursements-page.component.ts | 22 +- .../user-portal/user-portal.component.ts | 6 + APP/src/app/shared/i18n/option-lists.ts | 5 + 55 files changed, 3140 insertions(+), 85 deletions(-) create mode 100644 API/ROLAC.API.Tests/Services/AmountToWordsTests.cs create mode 100644 API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs create mode 100644 API/ROLAC.API/Controllers/ChurchProfileController.cs create mode 100644 API/ROLAC.API/Controllers/DisbursementsController.cs create mode 100644 API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs create mode 100644 API/ROLAC.API/DTOs/Disbursement/DisbursementDtos.cs create mode 100644 API/ROLAC.API/Entities/Check.cs create mode 100644 API/ROLAC.API/Entities/CheckLine.cs create mode 100644 API/ROLAC.API/Entities/ChurchProfile.cs create mode 100644 API/ROLAC.API/Services/ChurchProfileService.cs create mode 100644 API/ROLAC.API/Services/Disbursement/AmountToWords.cs create mode 100644 API/ROLAC.API/Services/Disbursement/CheckPrintService.cs create mode 100644 API/ROLAC.API/Services/Disbursement/ICheckPrintService.cs create mode 100644 API/ROLAC.API/Services/DisbursementService.cs create mode 100644 API/ROLAC.API/Services/IChurchProfileService.cs create mode 100644 API/ROLAC.API/Services/IDisbursementService.cs create mode 100644 APP/src/app/core/components/toast-container/toast-container.component.scss create mode 100644 APP/src/app/core/components/toast-container/toast-container.component.ts create mode 100644 APP/src/app/core/interceptors/http-error.interceptor.ts create mode 100644 APP/src/app/core/services/toast.service.ts create mode 100644 APP/src/app/features/disbursement/components/issue-check-dialog/issue-check-dialog.component.html create mode 100644 APP/src/app/features/disbursement/components/issue-check-dialog/issue-check-dialog.component.ts create mode 100644 APP/src/app/features/disbursement/components/receipt-sign-dialog/receipt-sign-dialog.component.html create mode 100644 APP/src/app/features/disbursement/components/receipt-sign-dialog/receipt-sign-dialog.component.ts create mode 100644 APP/src/app/features/disbursement/models/disbursement.model.ts create mode 100644 APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.html create mode 100644 APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.scss create mode 100644 APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.ts create mode 100644 APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html create mode 100644 APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts create mode 100644 APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.html create mode 100644 APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.scss create mode 100644 APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.ts create mode 100644 APP/src/app/features/disbursement/services/disbursement-api.service.ts diff --git a/API/ROLAC.API.Tests/Services/AmountToWordsTests.cs b/API/ROLAC.API.Tests/Services/AmountToWordsTests.cs new file mode 100644 index 0000000..45742dd --- /dev/null +++ b/API/ROLAC.API.Tests/Services/AmountToWordsTests.cs @@ -0,0 +1,29 @@ +using ROLAC.API.Services.Disbursement; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class AmountToWordsTests +{ + [Theory] + [InlineData(0, "Zero and 00/100 Dollars")] + [InlineData(0.05, "Zero and 05/100 Dollars")] + [InlineData(1, "One and 00/100 Dollars")] + [InlineData(19, "Nineteen and 00/100 Dollars")] + [InlineData(20, "Twenty and 00/100 Dollars")] + [InlineData(21, "Twenty-One and 00/100 Dollars")] + [InlineData(100, "One Hundred and 00/100 Dollars")] + [InlineData(115, "One Hundred Fifteen and 00/100 Dollars")] + [InlineData(1234.56, "One Thousand Two Hundred Thirty-Four and 56/100 Dollars")] + [InlineData(1000000, "One Million and 00/100 Dollars")] + public void Convert_FormatsExpectedWords(double amount, string expected) + { + Assert.Equal(expected, AmountToWords.Convert((decimal)amount)); + } + + [Fact] + public void Convert_RoundsCentsHalfUp() + { + Assert.Equal("One and 00/100 Dollars", AmountToWords.Convert(0.999m)); + } +} diff --git a/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs b/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs new file mode 100644 index 0000000..ad54383 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs @@ -0,0 +1,271 @@ +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Disbursement; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using ROLAC.API.Services.Disbursement; +using ROLAC.API.Services.Storage; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class DisbursementServiceTests +{ + private sealed class FakeStorage : IFileStorage + { + public Dictionary Files = new(); + public Task SaveAsync(Stream c, string p, CancellationToken ct = default) + { using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); } + public Task OpenReadAsync(string p, CancellationToken ct = default) + => Task.FromResult(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null); + public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; } + } + + private sealed class FakePrint : ICheckPrintService + { + public CheckPrintModel? LastReceiptModel; + + public Task RenderPdfAsync(CheckPrintModel model) + => Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes("pdf"))); + + public Task RenderReceiptPdfAsync(CheckPrintModel model) + { + LastReceiptModel = model; + return Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes("receipt-pdf"))); + } + } + + private static AppDbContext BuildDb(string userId) + { + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); + } + + private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId) + { + var http = new Mock(); + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; + http.Setup(x => x.HttpContext).Returns(ctx); + return new DisbursementService(db, http.Object, fs, new FakePrint()); + } + + private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin") + { + var db = BuildDb(userId); + db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 }); + db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" }); + db.SaveChanges(); + var fs = new FakeStorage(); + return (SvcAs(db, fs, userId), db, fs); + } + + private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new() + { + Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}", + MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1), + MemberId = memberId, VendorName = vendor, + }; + + [Fact] + public async Task GroupedWorklist_BundlesSamePayee() + { + var (svc, db, _) = Build(); + db.Expenses.AddRange( + Approved("StaffReimbursement", 10m, memberId: 1), + Approved("StaffReimbursement", 15m, memberId: 1), + Approved("VendorPayment", 30m, vendor: "Acme")); + await db.SaveChangesAsync(); + + var groups = await svc.GetApprovedUnpaidGroupedAsync(); + + Assert.Equal(2, groups.Count); + var member = groups.Single(g => g.PayeeType == "Member"); + Assert.Equal(25m, member.TotalAmount); + Assert.Equal(2, member.Lines.Count); + Assert.Equal("John Doe", member.PayeeName); + Assert.Equal("1 Main St", member.Address); + } + + [Fact] + public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers() + { + var (svc, db, _) = Build(); + var e1 = Approved("StaffReimbursement", 10m, memberId: 1); + var e2 = Approved("StaffReimbursement", 15m, memberId: 1); + var e3 = Approved("VendorPayment", 30m, vendor: "Acme"); + db.Expenses.AddRange(e1, e2, e3); + await db.SaveChangesAsync(); + + var req = new IssueChecksRequest + { + CheckDate = new DateOnly(2026, 6, 20), + Payees = + [ + new() { PayeeType = "Member", MemberId = 1, PayeeName = "John Doe", ExpenseIds = [e1.Id, e2.Id] }, + new() { PayeeType = "Vendor", VendorKey = "acme", PayeeName = "Acme", ExpenseIds = [e3.Id] }, + ], + }; + + var result = await svc.IssueChecksAsync(req); + + Assert.Equal(2, result.Created.Count); + Assert.Equal(new[] { "1001", "1002" }, result.Created.Select(c => c.CheckNumber).ToArray()); + Assert.All(await db.Expenses.ToListAsync(), e => Assert.Equal("Paid", e.Status)); + var memberCheck = await db.Checks.FirstAsync(c => c.PayeeType == "Member"); + Assert.Equal(25m, memberCheck.Amount); + Assert.Equal(1003, (await db.ChurchProfiles.FirstAsync()).NextCheckNumber); + } + + [Fact] + public async Task Issue_RejectsNonApprovedExpense() + { + var (svc, db, _) = Build(); + var e = Approved("VendorPayment", 30m, vendor: "Acme"); + e.Status = "Draft"; + db.Expenses.Add(e); + await db.SaveChangesAsync(); + + var req = new IssueChecksRequest + { + CheckDate = new DateOnly(2026, 6, 20), + Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }], + }; + + await Assert.ThrowsAsync(() => svc.IssueChecksAsync(req)); + } + + [Fact] + public async Task Void_RevertsExpensesToApproved() + { + var (svc, db, _) = Build(); + var e = Approved("VendorPayment", 30m, vendor: "Acme"); + db.Expenses.Add(e); + await db.SaveChangesAsync(); + var result = await svc.IssueChecksAsync(new IssueChecksRequest + { + CheckDate = new DateOnly(2026, 6, 20), + Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }], + }); + var checkId = result.Created[0].CheckId; + + await svc.VoidAsync(checkId, "wrong amount"); + + var check = await db.Checks.FirstAsync(c => c.Id == checkId); + Assert.Equal("Voided", check.Status); + var reverted = await db.Expenses.FirstAsync(x => x.Id == e.Id); + Assert.Equal("Approved", reverted.Status); + Assert.Null(reverted.CheckNumber); + Assert.Null(reverted.PaidAt); + } + + [Fact] + public async Task Acknowledge_StoresSignatureAndTimestamp() + { + var (svc, db, fs) = Build(); + var e = Approved("VendorPayment", 30m, vendor: "Acme"); + db.Expenses.Add(e); + await db.SaveChangesAsync(); + var result = await svc.IssueChecksAsync(new IssueChecksRequest + { + CheckDate = new DateOnly(2026, 6, 20), + Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }], + }); + var checkId = result.Created[0].CheckId; + + using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes")); + await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep"); + + var check = await db.Checks.FirstAsync(c => c.Id == checkId); + Assert.NotNull(check.ReceiptSignedAt); + Assert.Equal("Acme Rep", check.ReceiptSignedName); + Assert.NotNull(check.ReceiptSignatureBlobPath); + Assert.Single(fs.Files); + } + + private static (DisbursementService svc, AppDbContext db, FakeStorage fs, FakePrint print) BuildWithPrint(string userId = "fin") + { + var db = BuildDb(userId); + db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 }); + db.SaveChanges(); + var fs = new FakeStorage(); + var print = new FakePrint(); + var http = new Mock(); + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; + http.Setup(x => x.HttpContext).Returns(ctx); + return (new DisbursementService(db, http.Object, fs, print), db, fs, print); + } + + [Fact] + public async Task ReceiptPdf_NullWhenNotSigned() + { + var (svc, db, _, _) = BuildWithPrint(); + var e = Approved("VendorPayment", 30m, vendor: "Acme"); + db.Expenses.Add(e); + await db.SaveChangesAsync(); + var result = await svc.IssueChecksAsync(new IssueChecksRequest + { + CheckDate = new DateOnly(2026, 6, 20), + Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }], + }); + + var receipt = await svc.RenderReceiptPdfAsync(result.Created[0].CheckId); + + Assert.Null(receipt); + } + + [Fact] + public async Task ReceiptPdf_AfterSigning_RendersWithSignatureBytes() + { + var (svc, db, _, print) = BuildWithPrint(); + var e = Approved("VendorPayment", 30m, vendor: "Acme"); + db.Expenses.Add(e); + await db.SaveChangesAsync(); + var result = await svc.IssueChecksAsync(new IssueChecksRequest + { + CheckDate = new DateOnly(2026, 6, 20), + Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }], + }); + var checkId = result.Created[0].CheckId; + using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes")); + await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep"); + + var receipt = await svc.RenderReceiptPdfAsync(checkId); + + Assert.NotNull(receipt); + Assert.Equal("receipt-1001.pdf", receipt!.Value.fileName); + Assert.NotNull(print.LastReceiptModel); + Assert.NotNull(print.LastReceiptModel!.SignatureImage); + Assert.Equal("Acme Rep", print.LastReceiptModel.Check.ReceiptSignedName); + } + + [Fact] + public async Task Acknowledge_VoidedCheck_Throws() + { + var (svc, db, _) = Build(); + var e = Approved("VendorPayment", 30m, vendor: "Acme"); + db.Expenses.Add(e); + await db.SaveChangesAsync(); + var result = await svc.IssueChecksAsync(new IssueChecksRequest + { + CheckDate = new DateOnly(2026, 6, 20), + Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }], + }); + var checkId = result.Created[0].CheckId; + await svc.VoidAsync(checkId, null); + + using var img = new MemoryStream(Encoding.UTF8.GetBytes("png")); + await Assert.ThrowsAsync( + () => svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "X")); + } +} diff --git a/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs index a927cfa..a82393b 100644 --- a/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs @@ -98,16 +98,24 @@ public class ExpenseServiceTests } [Fact] - public async Task Create_Vendor_AsFinance_IsImmediatelyPaid() + public async Task Create_Vendor_AsFinance_IsPendingApproval() { var (svc, db, _) = Build(); var r = Reimb(); r.Type = "VendorPayment"; r.VendorName = "ABC"; r.CheckNumber = "2051"; var id = await svc.CreateAsync(r, isFinance: true); - Assert.Equal("Paid", (await db.Expenses.FindAsync(id))!.Status); + Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status); } [Fact] - public async Task Create_Reimbursement_IsDraft_WithSubmitter() + public async Task Create_Reimbursement_AsFinance_IsPendingApproval() + { + var (svc, db, _) = Build(); + var id = await svc.CreateAsync(Reimb(), isFinance: true); + Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status); + } + + [Fact] + public async Task Create_Reimbursement_AsMember_IsDraft_WithSubmitter() { var (svc, db, _) = Build("alice"); var id = await svc.CreateAsync(Reimb(), isFinance: false); diff --git a/API/ROLAC.API/Controllers/ChurchProfileController.cs b/API/ROLAC.API/Controllers/ChurchProfileController.cs new file mode 100644 index 0000000..0355ad2 --- /dev/null +++ b/API/ROLAC.API/Controllers/ChurchProfileController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Disbursement; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/church-profile")] +[Authorize(Roles = "finance,super_admin")] +public class ChurchProfileController : ControllerBase +{ + private readonly IChurchProfileService _svc; + public ChurchProfileController(IChurchProfileService svc) => _svc = svc; + + [HttpGet] + public async Task Get() => Ok(await _svc.GetAsync()); + + [HttpPut] + public async Task Update([FromBody] UpdateChurchProfileRequest r) + { + await _svc.UpdateAsync(r); + return NoContent(); + } +} diff --git a/API/ROLAC.API/Controllers/DisbursementsController.cs b/API/ROLAC.API/Controllers/DisbursementsController.cs new file mode 100644 index 0000000..c047fe2 --- /dev/null +++ b/API/ROLAC.API/Controllers/DisbursementsController.cs @@ -0,0 +1,95 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Disbursement; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/disbursements")] +[Authorize(Roles = "finance,super_admin")] +public class DisbursementsController : ControllerBase +{ + private readonly IDisbursementService _svc; + public DisbursementsController(IDisbursementService svc) => _svc = svc; + + [HttpGet("approved-unpaid")] + public async Task GetApprovedUnpaid() + => Ok(await _svc.GetApprovedUnpaidGroupedAsync()); + + [HttpPost("issue")] + public async Task Issue([FromBody] IssueChecksRequest r) + { + try { return Ok(await _svc.IssueChecksAsync(r)); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpGet("checks")] + public async Task GetRegister( + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null, + [FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) + => Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to)); + + [HttpGet("checks/{id:int}")] + public async Task GetById(int id) + { + var dto = await _svc.GetByIdAsync(id); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost("checks/{id:int}/void")] + public async Task Void(int id, [FromBody] VoidCheckRequest r) + { + try { await _svc.VoidAsync(id, r.Reason); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpGet("checks/{id:int}/pdf")] + public async Task GetPdf(int id) + { + var result = await _svc.RenderPdfAsync(id); + if (result is null) return NotFound(); + return File(result.Value.stream, result.Value.contentType, result.Value.fileName); + } + + [HttpGet("checks/{id:int}/receipt-pdf")] + public async Task GetReceiptPdf(int id) + { + var result = await _svc.RenderReceiptPdfAsync(id); + if (result is null) return NotFound(); + return File(result.Value.stream, result.Value.contentType, result.Value.fileName); + } + + [HttpPost("checks/{id:int}/acknowledge")] + [RequestSizeLimit(5_242_880)] + public async Task Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName) + { + if (signature is null || signature.Length == 0) return BadRequest(new { message = "No signature." }); + if (string.IsNullOrWhiteSpace(signedName)) return BadRequest(new { message = "Signed name is required." }); + var allowed = new[] { "image/png", "image/jpeg", "image/webp" }; + if (!allowed.Contains(signature.ContentType)) return BadRequest(new { message = "Unsupported image type." }); + try + { + await using var stream = signature.OpenReadStream(); + await _svc.AcknowledgeReceiptAsync(id, stream, signature.FileName, signedName.Trim()); + return NoContent(); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpGet("checks/{id:int}/signature")] + public async Task GetSignature(int id) + { + try + { + var result = await _svc.OpenSignatureAsync(id); + if (result is null) return NotFound(); + return File(result.Value.stream, result.Value.contentType); + } + catch (KeyNotFoundException) { return NotFound(); } + } +} diff --git a/API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs b/API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs new file mode 100644 index 0000000..2b7f472 --- /dev/null +++ b/API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Disbursement; + +public class ChurchProfileDto +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? BankName { get; set; } + public string? BankAccountNumber { get; set; } + public string? BankRoutingNumber { get; set; } + public int NextCheckNumber { get; set; } +} + +public class UpdateChurchProfileRequest +{ + [Required, MaxLength(200)] public string Name { get; set; } = ""; + [MaxLength(500)] public string? Address { get; set; } + [MaxLength(100)] public string? City { get; set; } + [MaxLength(50)] public string? State { get; set; } + [MaxLength(20)] public string? ZipCode { get; set; } + [MaxLength(200)] public string? BankName { get; set; } + [MaxLength(50)] public string? BankAccountNumber { get; set; } + [MaxLength(50)] public string? BankRoutingNumber { get; set; } + [Range(1, int.MaxValue)] public int NextCheckNumber { get; set; } +} diff --git a/API/ROLAC.API/DTOs/Disbursement/DisbursementDtos.cs b/API/ROLAC.API/DTOs/Disbursement/DisbursementDtos.cs new file mode 100644 index 0000000..87e0078 --- /dev/null +++ b/API/ROLAC.API/DTOs/Disbursement/DisbursementDtos.cs @@ -0,0 +1,107 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Disbursement; + +// ── Approved-unpaid expenses, grouped by payee (the issue-check worklist) ────── + +public class ExpenseLineDto +{ + public int ExpenseId { get; set; } + public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd + public string Description { get; set; } = ""; + public decimal Amount { get; set; } + public string MinistryName { get; set; } = ""; + public string CategoryName { get; set; } = ""; +} + +public class PayeeGroupDto +{ + public string PayeeType { get; set; } = "Vendor"; // Vendor | Member + public int? MemberId { get; set; } + public string? VendorKey { get; set; } // normalized vendor name (grouping key) + public string PayeeName { get; set; } = ""; + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public decimal TotalAmount { get; set; } + public List Lines { get; set; } = []; +} + +// ── Issue checks ────────────────────────────────────────────────────────────── + +public class PayeeCheckInstruction +{ + [Required] public string PayeeType { get; set; } = "Vendor"; + public int? MemberId { get; set; } + public string? VendorKey { get; set; } + [Required, MaxLength(200)] public string PayeeName { get; set; } = ""; + [MaxLength(500)] public string? Address { get; set; } + [MaxLength(100)] public string? City { get; set; } + [MaxLength(50)] public string? State { get; set; } + [MaxLength(20)] public string? Zip { get; set; } + [MaxLength(50)] public string? CheckNumberOverride { get; set; } + [MaxLength(500)] public string? Memo { get; set; } + [Required, MinLength(1)] public List ExpenseIds { get; set; } = []; +} + +public class IssueChecksRequest +{ + [Required] public DateOnly CheckDate { get; set; } + [Required, MinLength(1)] public List Payees { get; set; } = []; +} + +public class IssuedCheckDto +{ + public int CheckId { get; set; } + public string CheckNumber { get; set; } = ""; + public string PayeeName { get; set; } = ""; + public decimal Amount { get; set; } +} + +public class IssueChecksResultDto +{ + public List Created { get; set; } = []; +} + +// ── Check register / detail ─────────────────────────────────────────────────── + +public class CheckListItemDto +{ + public int Id { get; set; } + public string CheckNumber { get; set; } = ""; + public string CheckDate { get; set; } = ""; // yyyy-MM-dd + public decimal Amount { get; set; } + public string PayeeType { get; set; } = ""; + public string PayeeName { get; set; } = ""; + public string Status { get; set; } = ""; + public int LineCount { get; set; } + public bool Signed { get; set; } + public string? ReceiptSignedName { get; set; } + public DateTimeOffset? ReceiptSignedAt { get; set; } +} + +public class CheckLineDto +{ + public int ExpenseId { get; set; } + public string Description { get; set; } = ""; + public decimal Amount { get; set; } +} + +public class CheckDetailDto : CheckListItemDto +{ + public int? MemberId { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? Memo { get; set; } + public string? VoidReason { get; set; } + public DateTimeOffset? VoidedAt { get; set; } + public DateTimeOffset IssuedAt { get; set; } + public List Lines { get; set; } = []; +} + +public class VoidCheckRequest +{ + [MaxLength(500)] public string? Reason { get; set; } +} diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index aefba3d..d448719 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -19,6 +19,9 @@ public class AppDbContext : IdentityDbContext public DbSet ExpenseSubCategories => Set(); public DbSet Expenses => Set(); public DbSet MonthlyStatements => Set(); + public DbSet ChurchProfiles => Set(); + public DbSet Checks => Set(); + public DbSet CheckLines => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -210,6 +213,77 @@ public class AppDbContext : IdentityDbContext .HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull); }); + // ── ChurchProfile (singleton settings) ─────────────────────────────── + builder.Entity(entity => + { + entity.Property(e => e.Name).HasMaxLength(200).IsRequired(); + entity.Property(e => e.Address).HasMaxLength(500); + entity.Property(e => e.City).HasMaxLength(100); + entity.Property(e => e.State).HasMaxLength(50); + entity.Property(e => e.ZipCode).HasMaxLength(20); + entity.Property(e => e.BankName).HasMaxLength(200); + entity.Property(e => e.BankAccountNumber).HasMaxLength(50); + entity.Property(e => e.BankRoutingNumber).HasMaxLength(50); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + // Optimistic-concurrency token for safe check-number allocation. + entity.Property(e => e.xmin).IsRowVersion(); + }); + + // ── Check (disbursement) ───────────────────────────────────────────── + builder.Entity(entity => + { + entity.HasQueryFilter(c => !c.IsDeleted); + + entity.Property(e => e.CheckNumber).HasMaxLength(50).IsRequired(); + entity.Property(e => e.Amount).HasColumnType("decimal(18,2)"); + entity.Property(e => e.PayeeType).HasMaxLength(20).IsRequired(); + entity.Property(e => e.PayeeName).HasMaxLength(200).IsRequired(); + entity.Property(e => e.PayeeAddress).HasMaxLength(500); + entity.Property(e => e.PayeeCity).HasMaxLength(100); + entity.Property(e => e.PayeeState).HasMaxLength(50); + entity.Property(e => e.PayeeZip).HasMaxLength(20); + entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Issued"); + entity.Property(e => e.Memo).HasMaxLength(500); + entity.Property(e => e.IssuedBy).HasMaxLength(450).IsRequired(); + entity.Property(e => e.VoidReason).HasMaxLength(500); + entity.Property(e => e.VoidedBy).HasMaxLength(450); + entity.Property(e => e.ReceiptSignatureBlobPath).HasMaxLength(500); + entity.Property(e => e.ReceiptSignedName).HasMaxLength(200); + entity.Property(e => e.ReceiptCapturedBy).HasMaxLength(450); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + entity.Property(e => e.DeletedBy).HasMaxLength(450); + + // Unique check number among non-deleted rows. + entity.HasIndex(e => e.CheckNumber).IsUnique().HasFilter("\"IsDeleted\" = false"); + entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false"); + entity.HasIndex(e => e.CheckDate); + + entity.HasOne(e => e.Member).WithMany() + .HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull); + }); + + // ── CheckLine ──────────────────────────────────────────────────────── + builder.Entity(entity => + { + // Mirror the parent Check's soft-delete filter (required relationship). + entity.HasQueryFilter(l => !l.Check!.IsDeleted); + + entity.Property(e => e.Amount).HasColumnType("decimal(18,2)"); + entity.Property(e => e.Description).HasMaxLength(500).IsRequired(); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + + entity.HasIndex(e => e.CheckId); + entity.HasIndex(e => e.ExpenseId); + + entity.HasOne(e => e.Check).WithMany(c => c.Lines) + .HasForeignKey(e => e.CheckId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.Expense).WithMany() + .HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Restrict); + }); + // ── MonthlyStatement ───────────────────────────────────────────────── builder.Entity(entity => { diff --git a/API/ROLAC.API/Data/DbSeeder.cs b/API/ROLAC.API/Data/DbSeeder.cs index c8ecaa1..43e788c 100644 --- a/API/ROLAC.API/Data/DbSeeder.cs +++ b/API/ROLAC.API/Data/DbSeeder.cs @@ -130,6 +130,22 @@ public static class DbSeeder await db.SaveChangesAsync(); } + public static async Task SeedChurchProfileAsync(AppDbContext db) + { + // Singleton row used by the disbursement module (issuer info + check counter). + if (!await db.ChurchProfiles.AnyAsync()) + { + db.ChurchProfiles.Add(new ChurchProfile + { + Name = "River Of Life Christian Church", + City = "Arcadia", + State = "CA", + NextCheckNumber = 1001, + }); + await db.SaveChangesAsync(); + } + } + /// /// Seeds roles and (in Development) the default admin account. /// Called once on application startup after migrations have been applied. @@ -146,6 +162,7 @@ public static class DbSeeder await SeedGivingCategoriesAsync(db); await SeedMinistriesAsync(db); await SeedExpenseCategoriesAsync(db); + await SeedChurchProfileAsync(db); if (env.IsDevelopment()) await SeedAdminUserAsync(userManager); diff --git a/API/ROLAC.API/Entities/Check.cs b/API/ROLAC.API/Entities/Check.cs new file mode 100644 index 0000000..50a7384 --- /dev/null +++ b/API/ROLAC.API/Entities/Check.cs @@ -0,0 +1,43 @@ +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// +/// A disbursement check issued to a single payee, bundling one or more approved +/// expenses (its ). The payee name/address are snapshotted at +/// issue time so the printed check is reproducible even if member data later changes. +/// +public class Check : SoftDeleteEntity +{ + public int Id { get; set; } + public string CheckNumber { get; set; } = null!; + public DateOnly CheckDate { get; set; } + public decimal Amount { get; set; } // sum of line amounts + + public string PayeeType { get; set; } = "Vendor"; // Vendor | Member + public int? MemberId { get; set; } // set when PayeeType == Member + public string PayeeName { get; set; } = null!; // snapshot + public string? PayeeAddress { get; set; } // snapshot + public string? PayeeCity { get; set; } + public string? PayeeState { get; set; } + public string? PayeeZip { get; set; } + + public string Status { get; set; } = "Issued"; // Issued | Voided + public string? Memo { get; set; } + + public string IssuedBy { get; set; } = null!; + public DateTimeOffset IssuedAt { get; set; } + + public string? VoidReason { get; set; } + public DateTimeOffset? VoidedAt { get; set; } + public string? VoidedBy { get; set; } + + // Receipt e-signature: payee signs in-app on hand-off. "Signed" is derived as + // ReceiptSignedAt != null. + public string? ReceiptSignatureBlobPath { get; set; } + public string? ReceiptSignedName { get; set; } + public DateTimeOffset? ReceiptSignedAt { get; set; } + public string? ReceiptCapturedBy { get; set; } + + public Member? Member { get; set; } + public ICollection Lines { get; set; } = []; +} diff --git a/API/ROLAC.API/Entities/CheckLine.cs b/API/ROLAC.API/Entities/CheckLine.cs new file mode 100644 index 0000000..42bb51c --- /dev/null +++ b/API/ROLAC.API/Entities/CheckLine.cs @@ -0,0 +1,18 @@ +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// +/// One expense covered by a . Amount/Description are snapshotted +/// at issue time for the printed ledger stub; ExpenseId links back to the source expense. +/// +public class CheckLine : AuditableEntity +{ + public int Id { get; set; } + public int CheckId { get; set; } + public int ExpenseId { get; set; } + public decimal Amount { get; set; } // snapshot of expense amount + public string Description { get; set; } = null!; // snapshot of expense description + + public Check? Check { get; set; } + public Expense? Expense { get; set; } +} diff --git a/API/ROLAC.API/Entities/ChurchProfile.cs b/API/ROLAC.API/Entities/ChurchProfile.cs new file mode 100644 index 0000000..e00a338 --- /dev/null +++ b/API/ROLAC.API/Entities/ChurchProfile.cs @@ -0,0 +1,26 @@ +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// +/// Singleton (Id == 1) holding the issuing church's identity, bank details, and the +/// running check-number counter used when disbursing checks. Seeded on startup. +/// +public class ChurchProfile : AuditableEntity +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? BankName { get; set; } + public string? BankAccountNumber { get; set; } + public string? BankRoutingNumber { get; set; } + + /// Next check number to allocate; consumed (++) when a check is issued. + public int NextCheckNumber { get; set; } = 1001; + + // Npgsql system column used as an optimistic-concurrency token so two simultaneous + // disbursement runs can't allocate the same check number. Mapped via IsRowVersion(). + public uint xmin { get; set; } +} diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index 34ccd31..78a707c 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -245,6 +245,259 @@ namespace ROLAC.API.Migrations b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("ROLAC.API.Entities.Check", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckDate") + .HasColumnType("date"); + + b.Property("CheckNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("Memo") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PayeeAddress") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PayeeCity") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PayeeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PayeeState") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PayeeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PayeeZip") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ReceiptCapturedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ReceiptSignatureBlobPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptSignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReceiptSignedName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Issued"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("VoidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VoidedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("CheckDate"); + + b.HasIndex("CheckNumber") + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("MemberId"); + + b.HasIndex("Status") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Checks"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.CheckLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpenseId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("CheckId"); + + b.HasIndex("ExpenseId"); + + b.ToTable("CheckLines"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ChurchProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BankAccountNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BankName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BankRoutingNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NextCheckNumber") + .HasColumnType("integer"); + + b.Property("State") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ZipCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.ToTable("ChurchProfiles"); + }); + modelBuilder.Entity("ROLAC.API.Entities.Expense", b => { b.Property("Id") @@ -1058,6 +1311,35 @@ namespace ROLAC.API.Migrations .IsRequired(); }); + modelBuilder.Entity("ROLAC.API.Entities.Check", b => + { + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Member"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.CheckLine", b => + { + b.HasOne("ROLAC.API.Entities.Check", "Check") + .WithMany("Lines") + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.Expense", "Expense") + .WithMany() + .HasForeignKey("ExpenseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Check"); + + b.Navigation("Expense"); + }); + modelBuilder.Entity("ROLAC.API.Entities.Expense", b => { b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup") @@ -1154,6 +1436,11 @@ namespace ROLAC.API.Migrations b.Navigation("RefreshTokens"); }); + modelBuilder.Entity("ROLAC.API.Entities.Check", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => { b.Navigation("SubCategories"); diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 613ab63..38c8ec0 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -129,6 +129,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // --------------------------------------------------------------------------- // Swagger / MVC diff --git a/API/ROLAC.API/ROLAC.API.csproj b/API/ROLAC.API/ROLAC.API.csproj index 432453e..e78efd1 100644 --- a/API/ROLAC.API/ROLAC.API.csproj +++ b/API/ROLAC.API/ROLAC.API.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -7,6 +7,7 @@ + diff --git a/API/ROLAC.API/Services/ChurchProfileService.cs b/API/ROLAC.API/Services/ChurchProfileService.cs new file mode 100644 index 0000000..25bd443 --- /dev/null +++ b/API/ROLAC.API/Services/ChurchProfileService.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Disbursement; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class ChurchProfileService : IChurchProfileService +{ + private readonly AppDbContext _db; + public ChurchProfileService(AppDbContext db) => _db = db; + + public async Task GetAsync() + { + var p = await GetOrCreateAsync(); + return new ChurchProfileDto + { + Id = p.Id, Name = p.Name, Address = p.Address, City = p.City, State = p.State, + ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber, + BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber, + }; + } + + public async Task UpdateAsync(UpdateChurchProfileRequest r) + { + var p = await GetOrCreateAsync(); + p.Name = r.Name; p.Address = r.Address; p.City = r.City; p.State = r.State; + p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber; + p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber; + await _db.SaveChangesAsync(); + } + + private async Task GetOrCreateAsync() + { + var p = await _db.ChurchProfiles.OrderBy(x => x.Id).FirstOrDefaultAsync(); + if (p is null) + { + p = new ChurchProfile { Name = "Church", NextCheckNumber = 1001 }; + _db.ChurchProfiles.Add(p); + await _db.SaveChangesAsync(); + } + return p; + } +} diff --git a/API/ROLAC.API/Services/Disbursement/AmountToWords.cs b/API/ROLAC.API/Services/Disbursement/AmountToWords.cs new file mode 100644 index 0000000..8525e60 --- /dev/null +++ b/API/ROLAC.API/Services/Disbursement/AmountToWords.cs @@ -0,0 +1,75 @@ +namespace ROLAC.API.Services.Disbursement; + +/// +/// Converts a monetary amount to the English words used on a check, e.g. +/// 1234.56 → "One Thousand Two Hundred Thirty-Four and 56/100 Dollars". +/// Pure and dependency-free so it is easily unit-tested. +/// +public static class AmountToWords +{ + private static readonly string[] Ones = + [ + "Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", + "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", + "Seventeen", "Eighteen", "Nineteen", + ]; + + private static readonly string[] Tens = + [ + "", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety", + ]; + + // index 1.. → 10^(3*index) + private static readonly string[] Scales = ["", "Thousand", "Million", "Billion", "Trillion"]; + + public static string Convert(decimal amount) + { + if (amount < 0) amount = 0m; + // Round half-up to cents. + amount = Math.Round(amount, 2, MidpointRounding.AwayFromZero); + + var dollars = (long)Math.Floor(amount); + var cents = (int)Math.Round((amount - dollars) * 100m, MidpointRounding.AwayFromZero); + + var words = dollars == 0 ? "Zero" : ThreeDigitGroupsToWords(dollars); + return $"{words} and {cents:00}/100 Dollars"; + } + + private static string ThreeDigitGroupsToWords(long n) + { + // Split into groups of three digits, low to high. + var groups = new List(); + while (n > 0) { groups.Add((int)(n % 1000)); n /= 1000; } + + var parts = new List(); + for (var i = groups.Count - 1; i >= 0; i--) + { + if (groups[i] == 0) continue; + var group = HundredsToWords(groups[i]); + var scale = Scales[i]; + parts.Add(string.IsNullOrEmpty(scale) ? group : $"{group} {scale}"); + } + return string.Join(" ", parts); + } + + private static string HundredsToWords(int n) + { + var parts = new List(); + if (n >= 100) + { + parts.Add($"{Ones[n / 100]} Hundred"); + n %= 100; + } + if (n >= 20) + { + var t = Tens[n / 10]; + var o = n % 10; + parts.Add(o == 0 ? t : $"{t}-{Ones[o]}"); + } + else if (n > 0) + { + parts.Add(Ones[n]); + } + return string.Join(" ", parts); + } +} diff --git a/API/ROLAC.API/Services/Disbursement/CheckPrintService.cs b/API/ROLAC.API/Services/Disbursement/CheckPrintService.cs new file mode 100644 index 0000000..0e21b97 --- /dev/null +++ b/API/ROLAC.API/Services/Disbursement/CheckPrintService.cs @@ -0,0 +1,245 @@ +using System.Globalization; +using DevExpress.Office; +using DevExpress.XtraRichEdit; +using DevExpress.XtraRichEdit.API.Native; + +namespace ROLAC.API.Services.Disbursement; + +/// +/// Renders a check on 8.5"x11" stock using the DevExpress Office (RichEdit) API: +/// a check block on top followed by two identical ledger detail stubs. The layout is +/// built programmatically (no external .docx template) and exported to PDF. +/// +public class CheckPrintService : ICheckPrintService +{ + public Task RenderPdfAsync(CheckPrintModel model) + { + using var server = new RichEditDocumentServer(); + var doc = server.Document; + doc.BeginUpdate(); + try + { + doc.Unit = DocumentUnit.Inch; + var section = doc.Sections[0]; + section.Page.Width = 8.5f; + section.Page.Height = 11f; + section.Margins.Left = section.Margins.Right = 0.6f; + section.Margins.Top = section.Margins.Bottom = 0.5f; + + BuildCheckBlock(doc, model); + BuildStub(doc, model, "PAYMENT ADVICE — DETAIL"); + BuildStub(doc, model, "PAYMENT ADVICE — RECORD COPY"); + } + finally + { + doc.EndUpdate(); + } + + var ms = new MemoryStream(); + server.ExportToPdf(ms); + ms.Position = 0; + return Task.FromResult(ms); + } + + public Task RenderReceiptPdfAsync(CheckPrintModel model) + { + using var server = new RichEditDocumentServer(); + var document = server.Document; + document.BeginUpdate(); + try + { + document.Unit = DocumentUnit.Inch; + var section = document.Sections[0]; + section.Page.Width = 8.5f; + section.Page.Height = 11f; + section.Margins.Left = section.Margins.Right = 0.8f; + section.Margins.Top = section.Margins.Bottom = 0.8f; + + document.AppendHtmlText(BuildReceiptHtml(model)); + } + finally + { + document.EndUpdate(); + } + + var stream = new MemoryStream(); + server.ExportToPdf(stream); + stream.Position = 0; + return Task.FromResult(stream); + } + + private static string BuildReceiptHtml(CheckPrintModel model) + { + var issuer = model.Issuer; + var check = model.Check; + + var issuerAddress = Encode(JoinAddress(issuer.Address, issuer.City, issuer.State, issuer.ZipCode)); + var payeeAddress = Encode(JoinAddress(check.PayeeAddress, check.PayeeCity, check.PayeeState, check.PayeeZip)); + + var detailRows = new System.Text.StringBuilder(); + foreach (var line in model.Lines) + { + var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? ""; + detailRows.Append( + "" + + $"{Encode(date)}" + + $"{Encode(line.Description)}" + + $"{Encode(FormatCurrency(line.Amount))}" + + ""); + } + + var signedOn = check.ReceiptSignedAt.HasValue + ? check.ReceiptSignedAt.Value.ToLocalTime().ToString("MM/dd/yyyy HH:mm") + : ""; + + var signatureBlock = ""; + if (model.SignatureImage is { Length: > 0 }) + { + var base64 = Convert.ToBase64String(model.SignatureImage); + signatureBlock = + $"

"; + } + + return + "
" + + "

Disbursement Receipt / 簽收收據

" + + $"

{Encode(issuer.Name)}
{issuerAddress}

" + + + "

Check Information / 支票資訊

" + + "" + + $"" + + $"" + + $"" + + $"" + + $"" + + "
Check No. / 支票號碼{Encode(check.CheckNumber)}Date / 日期{check.CheckDate:MM/dd/yyyy}
Payee / 收款人{Encode(check.PayeeName)} {payeeAddress}
Amount / 金額{Encode(FormatCurrency(check.Amount))} — {Encode(model.AmountInWords)}
Memo / 摘要{Encode(check.Memo ?? "")}
" + + + "

Disbursement Detail / 撥款明細

" + + "" + + "" + + detailRows + + $"" + + "
Date / 日期Description / 說明Amount / 金額
TOTAL / 合計{Encode(FormatCurrency(check.Amount))}
" + + + "

Acknowledgement of Receipt / 收款簽收

" + + "

I acknowledge receipt of the above payment in full. / 本人確認已如數收到上述款項。

" + + $"

Received by / 簽收人: {Encode(check.ReceiptSignedName ?? "")}
" + + $"Date / 簽收日期: {Encode(signedOn)}

" + + signatureBlock + + "
"; + } + + private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? ""); + + private static void BuildCheckBlock(Document doc, CheckPrintModel m) + { + var issuer = m.Issuer; + var check = m.Check; + + AppendLine(doc, issuer.Name, bold: true, size: 13); + var issuerAddr = JoinAddress(issuer.Address, issuer.City, issuer.State, issuer.ZipCode); + if (!string.IsNullOrWhiteSpace(issuerAddr)) AppendLine(doc, issuerAddr, size: 9); + if (!string.IsNullOrWhiteSpace(issuer.BankName)) AppendLine(doc, issuer.BankName, size: 9); + + AppendLine(doc, ""); + AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy}", bold: true, size: 10); + AppendLine(doc, ""); + + AppendLine(doc, $"PAY TO THE ORDER OF: {check.PayeeName}", bold: true, size: 11); + var payeeAddr = JoinAddress(check.PayeeAddress, check.PayeeCity, check.PayeeState, check.PayeeZip); + if (!string.IsNullOrWhiteSpace(payeeAddr)) AppendLine(doc, payeeAddr, size: 9); + + AppendLine(doc, ""); + AppendLine(doc, $"AMOUNT: {FormatCurrency(check.Amount)}", bold: true, size: 12); + AppendLine(doc, m.AmountInWords, size: 10); + + if (!string.IsNullOrWhiteSpace(check.Memo)) { AppendLine(doc, ""); AppendLine(doc, $"Memo: {check.Memo}", size: 9); } + + AppendLine(doc, ""); + AppendLine(doc, "____________________________________", size: 10); + AppendLine(doc, "Authorized Signature", size: 8); + AppendSeparator(doc); + } + + private static void BuildStub(Document doc, CheckPrintModel m, string title) + { + var check = m.Check; + AppendLine(doc, title, bold: true, size: 10); + AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy} Payee: {check.PayeeName}", size: 9); + AppendLine(doc, ""); + + var rows = m.Lines.Count + 2; // header + lines + total + var table = doc.Tables.Create(doc.Range.End, rows, 3, AutoFitBehaviorType.AutoFitToWindow); + table.Borders.InsideHorizontalBorder.LineStyle = TableBorderLineStyle.Single; + table.Borders.Top.LineStyle = table.Borders.Bottom.LineStyle = TableBorderLineStyle.Single; + + SetCell(doc, table[0, 0], "Date", bold: true); + SetCell(doc, table[0, 1], "Description", bold: true); + SetCell(doc, table[0, 2], "Amount", bold: true, right: true); + + for (var i = 0; i < m.Lines.Count; i++) + { + var line = m.Lines[i]; + var r = i + 1; + // CheckLine snapshots description; date comes from the source expense if loaded. + var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? ""; + SetCell(doc, table[r, 0], date); + SetCell(doc, table[r, 1], line.Description); + SetCell(doc, table[r, 2], FormatCurrency(line.Amount), right: true); + } + + var totalRow = rows - 1; + SetCell(doc, table[totalRow, 0], ""); + SetCell(doc, table[totalRow, 1], "TOTAL", bold: true, right: true); + SetCell(doc, table[totalRow, 2], FormatCurrency(check.Amount), bold: true, right: true); + + AppendLine(doc, ""); + if (check.ReceiptSignedAt is { } signedAt) + AppendLine(doc, $"Received by: {check.ReceiptSignedName} on {signedAt:MM/dd/yyyy HH:mm}", size: 9); + AppendSeparator(doc); + } + + // ── low-level helpers ────────────────────────────────────────────────────── + + private static void AppendLine(Document doc, string text, bool bold = false, float size = 10) + { + var range = doc.AppendText(text + "\r\n"); + var cp = doc.BeginUpdateCharacters(range); + cp.Bold = bold; + cp.FontSize = size; + doc.EndUpdateCharacters(cp); + } + + private static void AppendSeparator(Document doc) + { + AppendLine(doc, ""); + AppendLine(doc, "------------------------------------------------------------------------------------------", size: 8); + AppendLine(doc, ""); + } + + private static void SetCell(Document doc, TableCell cell, string text, bool bold = false, bool right = false) + { + var range = doc.InsertText(cell.ContentRange.Start, text); + var cp = doc.BeginUpdateCharacters(range); + cp.Bold = bold; + cp.FontSize = 9; + doc.EndUpdateCharacters(cp); + if (right) + { + var pp = doc.BeginUpdateParagraphs(range); + pp.Alignment = ParagraphAlignment.Right; + doc.EndUpdateParagraphs(pp); + } + } + + private static string FormatCurrency(decimal amount) => + amount.ToString("C2", CultureInfo.GetCultureInfo("en-US")); + + private static string JoinAddress(string? addr, string? city, string? state, string? zip) + { + var cityLine = string.Join(", ", + new[] { city, string.Join(" ", new[] { state, zip }.Where(s => !string.IsNullOrWhiteSpace(s))) } + .Where(s => !string.IsNullOrWhiteSpace(s))); + return string.Join(" ", new[] { addr, cityLine }.Where(s => !string.IsNullOrWhiteSpace(s))); + } +} diff --git a/API/ROLAC.API/Services/Disbursement/ICheckPrintService.cs b/API/ROLAC.API/Services/Disbursement/ICheckPrintService.cs new file mode 100644 index 0000000..2d21eb6 --- /dev/null +++ b/API/ROLAC.API/Services/Disbursement/ICheckPrintService.cs @@ -0,0 +1,28 @@ +using ROLAC.API.Entities; + +namespace ROLAC.API.Services.Disbursement; + +/// Data needed to render one printed check (header + ledger stub lines). +public class CheckPrintModel +{ + public ChurchProfile Issuer { get; set; } = null!; + public Check Check { get; set; } = null!; + public List Lines { get; set; } = []; + public string AmountInWords { get; set; } = ""; + + // Captured receipt e-signature, populated only when rendering a signed receipt. + public byte[]? SignatureImage { get; set; } + public string SignatureContentType { get; set; } = "image/png"; +} + +public interface ICheckPrintService +{ + /// Renders the 8.5"x11" check (check + two ledger stubs) to a PDF stream. + Task RenderPdfAsync(CheckPrintModel model); + + /// + /// Renders a receipt PDF acknowledging a signed check: check info, the disbursement + /// detail lines, and the embedded e-signature image with the signer name and timestamp. + /// + Task RenderReceiptPdfAsync(CheckPrintModel model); +} diff --git a/API/ROLAC.API/Services/DisbursementService.cs b/API/ROLAC.API/Services/DisbursementService.cs new file mode 100644 index 0000000..e1beae9 --- /dev/null +++ b/API/ROLAC.API/Services/DisbursementService.cs @@ -0,0 +1,363 @@ +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Disbursement; +using ROLAC.API.DTOs.Shared; +using ROLAC.API.Entities; +using ROLAC.API.Services.Disbursement; +using ROLAC.API.Services.Storage; + +namespace ROLAC.API.Services; + +public class DisbursementService : IDisbursementService +{ + private readonly AppDbContext _db; + private readonly IHttpContextAccessor _http; + private readonly IFileStorage _storage; + private readonly ICheckPrintService _print; + + public DisbursementService(AppDbContext db, IHttpContextAccessor http, + IFileStorage storage, ICheckPrintService print) + { _db = db; _http = http; _storage = storage; _print = print; } + + // The JWT carries the user id in the "sub" claim (NameClaimType="sub"); NameIdentifier + // is absent at runtime. Check NameIdentifier first (tests), then "sub" (real tokens). + private string CurrentUserId => + _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? _http.HttpContext?.User.FindFirstValue("sub") + ?? "system"; + + // ── Worklist: approved-unpaid expenses grouped by payee ────────────────────── + public async Task> GetApprovedUnpaidGroupedAsync() + { + var rows = await _db.Expenses.AsNoTracking().Where(e => e.Status == "Approved").ToListAsync(); + + var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => m.Name_en); + var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en); + var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet(); + var members = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)).ToDictionaryAsync(m => m.Id); + + var groups = new Dictionary(); + foreach (var e in rows) + { + PayeeGroupDto g; + if (e.Type == "VendorPayment") + { + var vname = (e.VendorName ?? "").Trim(); + var vkey = vname.ToLowerInvariant(); + var key = "V:" + vkey; + if (!groups.TryGetValue(key, out g!)) + { + g = new PayeeGroupDto { PayeeType = "Vendor", VendorKey = vkey, PayeeName = vname }; + groups[key] = g; + } + } + else + { + var mid = e.MemberId ?? 0; + var key = "M:" + mid; + if (!groups.TryGetValue(key, out g!)) + { + members.TryGetValue(mid, out var mem); + g = new PayeeGroupDto + { + PayeeType = "Member", MemberId = e.MemberId, + PayeeName = mem != null ? $"{mem.FirstName_en} {mem.LastName_en}" : "(Unknown member)", + Address = mem?.Address, City = mem?.City, State = mem?.State, Zip = mem?.ZipCode, + }; + groups[key] = g; + } + } + + g.Lines.Add(new ExpenseLineDto + { + ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), + Description = e.Description, Amount = e.Amount, + MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""), + CategoryName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""), + }); + g.TotalAmount += e.Amount; + } + + return groups.Values.OrderBy(g => g.PayeeName).ToList(); + } + + // ── Issue checks (one per payee group) ────────────────────────────────────── + public async Task IssueChecksAsync(IssueChecksRequest r) + { + var result = new IssueChecksResultDto(); + await using var tx = await _db.Database.BeginTransactionAsync(); + + var profile = await _db.ChurchProfiles.OrderBy(x => x.Id).FirstOrDefaultAsync(); + if (profile is null) + { + profile = new ChurchProfile { Name = "Church", NextCheckNumber = 1001 }; + _db.ChurchProfiles.Add(profile); + } + + foreach (var p in r.Payees) + { + var expenses = await _db.Expenses.Where(e => p.ExpenseIds.Contains(e.Id)).ToListAsync(); + if (expenses.Count != p.ExpenseIds.Distinct().Count()) + throw new KeyNotFoundException("One or more selected expenses no longer exist."); + foreach (var e in expenses) + if (e.Status != "Approved") + throw new InvalidOperationException($"Expense {e.Id} is not Approved (status '{e.Status}')."); + + // Guard against double-payment: none of these may already sit on an issued check. + var alreadyLinked = await ( + from l in _db.CheckLines + join c in _db.Checks on l.CheckId equals c.Id + where p.ExpenseIds.Contains(l.ExpenseId) && c.Status == "Issued" + select l.Id).AnyAsync(); + if (alreadyLinked) + throw new InvalidOperationException("One or more selected expenses are already on an issued check."); + + string checkNumber; + if (!string.IsNullOrWhiteSpace(p.CheckNumberOverride)) + checkNumber = p.CheckNumberOverride.Trim(); + else + { + checkNumber = profile.NextCheckNumber.ToString(); + profile.NextCheckNumber++; + } + + var amount = expenses.Sum(e => e.Amount); + var paidAt = new DateTimeOffset(r.CheckDate.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + var check = new Check + { + CheckNumber = checkNumber, CheckDate = r.CheckDate, Amount = amount, + PayeeType = p.PayeeType, MemberId = p.PayeeType == "Member" ? p.MemberId : null, + PayeeName = p.PayeeName, PayeeAddress = p.Address, PayeeCity = p.City, + PayeeState = p.State, PayeeZip = p.Zip, + Status = "Issued", Memo = p.Memo, IssuedBy = CurrentUserId, IssuedAt = DateTimeOffset.UtcNow, + }; + foreach (var e in expenses) + { + check.Lines.Add(new CheckLine { ExpenseId = e.Id, Amount = e.Amount, Description = e.Description }); + e.Status = "Paid"; e.CheckNumber = checkNumber; e.PaidBy = CurrentUserId; e.PaidAt = paidAt; + } + _db.Checks.Add(check); + + try + { + await _db.SaveChangesAsync(); // assigns check.Id and consumes the number + } + catch (DbUpdateConcurrencyException) + { + throw new InvalidOperationException("Check numbering changed concurrently. Please retry."); + } + catch (DbUpdateException) + { + // The unique index on CheckNumber rejected a duplicate (e.g. an overridden number + // that already exists, including a previously voided check that kept its number). + throw new InvalidOperationException( + $"Check number '{checkNumber}' is already in use. Choose a different number."); + } + + result.Created.Add(new IssuedCheckDto + { CheckId = check.Id, CheckNumber = checkNumber, PayeeName = p.PayeeName, Amount = amount }); + } + + await tx.CommitAsync(); + return result; + } + + // ── Check register ────────────────────────────────────────────────────────── + public async Task> GetRegisterAsync( + int page, int pageSize, string? status, string? search, DateOnly? from, DateOnly? to) + { + var q = _db.Checks.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(status)) q = q.Where(c => c.Status == status); + if (from.HasValue) q = q.Where(c => c.CheckDate >= from.Value); + if (to.HasValue) q = q.Where(c => c.CheckDate <= to.Value); + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + q = q.Where(c => c.CheckNumber.ToLower().Contains(s) || c.PayeeName.ToLower().Contains(s)); + } + + var total = await q.CountAsync(); + var rows = await q.OrderByDescending(c => c.IssuedAt).ThenByDescending(c => c.Id) + .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); + + var ids = rows.Select(c => c.Id).ToList(); + var counts = await _db.CheckLines.AsNoTracking().Where(l => ids.Contains(l.CheckId)) + .GroupBy(l => l.CheckId).Select(g => new { g.Key, C = g.Count() }) + .ToDictionaryAsync(x => x.Key, x => x.C); + + var items = rows.Select(c => ToListItem(c, counts.GetValueOrDefault(c.Id))).ToList(); + return new PagedResult { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; + } + + public async Task GetByIdAsync(int id) + { + var c = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + if (c is null) return null; + var lines = await _db.CheckLines.AsNoTracking().Where(l => l.CheckId == id).OrderBy(l => l.Id).ToListAsync(); + + var dto = new CheckDetailDto + { + MemberId = c.MemberId, Address = c.PayeeAddress, City = c.PayeeCity, State = c.PayeeState, + Zip = c.PayeeZip, Memo = c.Memo, VoidReason = c.VoidReason, VoidedAt = c.VoidedAt, + IssuedAt = c.IssuedAt, + Lines = lines.Select(l => new CheckLineDto + { ExpenseId = l.ExpenseId, Description = l.Description, Amount = l.Amount }).ToList(), + }; + CopyListFields(c, dto, lines.Count); + return dto; + } + + // ── Void (revert expenses to Approved) ────────────────────────────────────── + public async Task VoidAsync(int id, string? reason) + { + await using var tx = await _db.Database.BeginTransactionAsync(); + var c = await _db.Checks.FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"Check {id} not found."); + if (c.Status != "Issued") throw new InvalidOperationException($"Cannot void a check with status '{c.Status}'."); + + c.Status = "Voided"; c.VoidReason = reason; c.VoidedAt = DateTimeOffset.UtcNow; c.VoidedBy = CurrentUserId; + + var lines = await _db.CheckLines.Where(l => l.CheckId == id).ToListAsync(); + var expIds = lines.Select(l => l.ExpenseId).ToList(); + var exps = await _db.Expenses.Where(e => expIds.Contains(e.Id)).ToListAsync(); + foreach (var e in exps) + { + e.Status = "Approved"; e.CheckNumber = null; e.PaidAt = null; e.PaidBy = null; + } + await _db.SaveChangesAsync(); + await tx.CommitAsync(); + } + + // ── Receipt e-signature ───────────────────────────────────────────────────── + public async Task AcknowledgeReceiptAsync(int id, Stream signature, string fileName, string signedName) + { + var c = await _db.Checks.FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"Check {id} not found."); + if (c.Status != "Issued") throw new InvalidOperationException("Cannot sign a voided check."); + if (c.ReceiptSignedAt is not null) throw new InvalidOperationException("This check has already been signed."); + + var ext = Path.GetExtension(fileName); + if (string.IsNullOrWhiteSpace(ext)) ext = ".png"; + var path = $"finance/check-signatures/{c.CheckDate.Year}/{c.CheckDate.Month}/{c.Id}{ext}"; + var saved = await _storage.SaveAsync(signature, path); + + c.ReceiptSignatureBlobPath = saved; + c.ReceiptSignedName = signedName; + c.ReceiptSignedAt = DateTimeOffset.UtcNow; + c.ReceiptCapturedBy = CurrentUserId; + await _db.SaveChangesAsync(); + } + + public async Task<(Stream stream, string contentType)?> OpenSignatureAsync(int id) + { + var c = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"Check {id} not found."); + if (c.ReceiptSignatureBlobPath is null) return null; + var stream = await _storage.OpenReadAsync(c.ReceiptSignatureBlobPath); + if (stream is null) return null; + return (stream, SignatureContentType(c.ReceiptSignatureBlobPath)); + } + + // ── Render PDF ────────────────────────────────────────────────────────────── + public async Task<(Stream stream, string contentType, string fileName)?> RenderPdfAsync(int id) + { + var c = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + if (c is null) return null; + + var lines = await _db.CheckLines.AsNoTracking().Where(l => l.CheckId == id).OrderBy(l => l.Id).ToListAsync(); + var expIds = lines.Select(l => l.ExpenseId).ToList(); + var exps = await _db.Expenses.AsNoTracking().IgnoreQueryFilters() + .Where(e => expIds.Contains(e.Id)).ToDictionaryAsync(e => e.Id); + foreach (var l in lines) l.Expense = exps.GetValueOrDefault(l.ExpenseId); + + var profile = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync() + ?? new ChurchProfile { Name = "Church" }; + + var model = new CheckPrintModel + { + Issuer = profile, Check = c, Lines = lines, AmountInWords = AmountToWords.Convert(c.Amount), + }; + var stream = await _print.RenderPdfAsync(model); + return (stream, "application/pdf", $"check-{c.CheckNumber}.pdf"); + } + + // ── Render signed receipt PDF ──────────────────────────────────────────────── + public async Task<(Stream stream, string contentType, string fileName)?> RenderReceiptPdfAsync(int id) + { + var check = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + if (check is null) + { + return null; + } + // A receipt only exists once the payee has signed. + if (check.ReceiptSignedAt is null || check.ReceiptSignatureBlobPath is null) + { + return null; + } + + var lines = await _db.CheckLines.AsNoTracking() + .Where(line => line.CheckId == id).OrderBy(line => line.Id).ToListAsync(); + var expenseIds = lines.Select(line => line.ExpenseId).ToList(); + var expenses = await _db.Expenses.AsNoTracking().IgnoreQueryFilters() + .Where(expense => expenseIds.Contains(expense.Id)).ToDictionaryAsync(expense => expense.Id); + foreach (var line in lines) + { + line.Expense = expenses.GetValueOrDefault(line.ExpenseId); + } + + var profile = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync() + ?? new ChurchProfile { Name = "Church" }; + + byte[]? signatureBytes = null; + var signatureStream = await _storage.OpenReadAsync(check.ReceiptSignatureBlobPath); + if (signatureStream is not null) + { + await using (signatureStream) + { + using var buffer = new MemoryStream(); + await signatureStream.CopyToAsync(buffer); + signatureBytes = buffer.ToArray(); + } + } + + var model = new CheckPrintModel + { + Issuer = profile, + Check = check, + Lines = lines, + AmountInWords = AmountToWords.Convert(check.Amount), + SignatureImage = signatureBytes, + SignatureContentType = SignatureContentType(check.ReceiptSignatureBlobPath), + }; + var stream = await _print.RenderReceiptPdfAsync(model); + return (stream, "application/pdf", $"receipt-{check.CheckNumber}.pdf"); + } + + private static string SignatureContentType(string path) + { + var ext = Path.GetExtension(path).ToLowerInvariant(); + return ext switch + { + ".jpg" or ".jpeg" => "image/jpeg", + ".webp" => "image/webp", + _ => "image/png", + }; + } + + // ── helpers ───────────────────────────────────────────────────────────────── + private static CheckListItemDto ToListItem(Check c, int lineCount) + { + var dto = new CheckListItemDto(); + CopyListFields(c, dto, lineCount); + return dto; + } + + private static void CopyListFields(Check c, CheckListItemDto dto, int lineCount) + { + dto.Id = c.Id; dto.CheckNumber = c.CheckNumber; dto.CheckDate = c.CheckDate.ToString("yyyy-MM-dd"); + dto.Amount = c.Amount; dto.PayeeType = c.PayeeType; dto.PayeeName = c.PayeeName; dto.Status = c.Status; + dto.LineCount = lineCount; dto.Signed = c.ReceiptSignedAt is not null; + dto.ReceiptSignedName = c.ReceiptSignedName; dto.ReceiptSignedAt = c.ReceiptSignedAt; + } +} diff --git a/API/ROLAC.API/Services/ExpenseService.cs b/API/ROLAC.API/Services/ExpenseService.cs index 76a6d9c..b08f53e 100644 --- a/API/ROLAC.API/Services/ExpenseService.cs +++ b/API/ROLAC.API/Services/ExpenseService.cs @@ -76,9 +76,9 @@ public class ExpenseService : IExpenseService .OrderByDescending(e => e.ExpenseDate).ThenByDescending(e => e.Id) .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); - var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => m.Name_en); - var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en); - var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => s.Name_en); + var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}"); + var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}"); + var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => $"{s.Name_en} / {s.Name_zh}"); var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet(); var memNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)) .ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}"); @@ -134,14 +134,18 @@ public class ExpenseService : IExpenseService if (r.Type == "VendorPayment") { if (!isFinance) throw new InvalidOperationException("Only finance can create vendor payments."); - e.Status = "Paid"; - e.PaidAt = DateTimeOffset.UtcNow; e.PaidBy = CurrentUserId; + // Enters the approval queue: PendingApproval -> Approve -> Pay (issue check). + e.Status = "PendingApproval"; + e.SubmittedBy = CurrentUserId; e.SubmittedAt = DateTimeOffset.UtcNow; e.MemberId = null; } else // StaffReimbursement { - e.Status = "Draft"; + // Finance entering on behalf of a member goes straight to the approval queue; + // a member's own self-service entry stays a Draft until they explicitly Submit it. + e.Status = isFinance ? "PendingApproval" : "Draft"; e.SubmittedBy = CurrentUserId; + if (isFinance) e.SubmittedAt = DateTimeOffset.UtcNow; e.MemberId = isFinance ? r.MemberId : await CallerMemberIdAsync(); e.VendorName = null; } diff --git a/API/ROLAC.API/Services/IChurchProfileService.cs b/API/ROLAC.API/Services/IChurchProfileService.cs new file mode 100644 index 0000000..1d71840 --- /dev/null +++ b/API/ROLAC.API/Services/IChurchProfileService.cs @@ -0,0 +1,9 @@ +using ROLAC.API.DTOs.Disbursement; + +namespace ROLAC.API.Services; + +public interface IChurchProfileService +{ + Task GetAsync(); + Task UpdateAsync(UpdateChurchProfileRequest r); +} diff --git a/API/ROLAC.API/Services/IDisbursementService.cs b/API/ROLAC.API/Services/IDisbursementService.cs new file mode 100644 index 0000000..2de21c9 --- /dev/null +++ b/API/ROLAC.API/Services/IDisbursementService.cs @@ -0,0 +1,18 @@ +using ROLAC.API.DTOs.Disbursement; +using ROLAC.API.DTOs.Shared; + +namespace ROLAC.API.Services; + +public interface IDisbursementService +{ + Task> GetApprovedUnpaidGroupedAsync(); + Task IssueChecksAsync(IssueChecksRequest r); + Task> GetRegisterAsync( + int page, int pageSize, string? status, string? search, DateOnly? from, DateOnly? to); + Task GetByIdAsync(int id); + Task VoidAsync(int id, string? reason); + Task AcknowledgeReceiptAsync(int id, Stream signature, string fileName, string signedName); + Task<(Stream stream, string contentType)?> OpenSignatureAsync(int id); + Task<(Stream stream, string contentType, string fileName)?> RenderPdfAsync(int id); + Task<(Stream stream, string contentType, string fileName)?> RenderReceiptPdfAsync(int id); +} diff --git a/APP/src/app/app.config.ts b/APP/src/app/app.config.ts index 428bd7c..ede750b 100644 --- a/APP/src/app/app.config.ts +++ b/APP/src/app/app.config.ts @@ -5,13 +5,16 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; import { authInterceptor } from './core/interceptors/auth.interceptor'; +import { httpErrorInterceptor } from './core/interceptors/http-error.interceptor'; import { AuthService } from './shared/services/auth.service'; export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), provideAnimations(), - provideHttpClient(withInterceptors([authInterceptor])), + // httpErrorInterceptor is listed first so its catchError runs LAST (outermost), + // i.e. after authInterceptor has handled/retried 401s. + provideHttpClient(withInterceptors([httpErrorInterceptor, authInterceptor])), { provide: APP_INITIALIZER, useFactory: (authService: AuthService) => () => authService.initializeFromRefreshToken(), diff --git a/APP/src/app/app.html b/APP/src/app/app.html index 09af448..730999c 100644 --- a/APP/src/app/app.html +++ b/APP/src/app/app.html @@ -1,2 +1,3 @@ -
\ No newline at end of file +
+ \ No newline at end of file diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index 263f530..a189b3c 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -14,6 +14,9 @@ import { ExpensesPageComponent } from './features/expense/pages/expenses-page/ex import { MyReimbursementsPageComponent } from './features/expense/pages/my-reimbursements-page/my-reimbursements-page.component'; import { MonthlyStatementPageComponent } from './features/expense/pages/monthly-statement-page/monthly-statement-page.component'; import { FinanceDashboardPageComponent } from './features/finance-dashboard/pages/finance-dashboard-page/finance-dashboard-page.component'; +import { DisbursementPageComponent } from './features/disbursement/pages/disbursement-page/disbursement-page.component'; +import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component'; +import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component'; export const routes: Routes = [ // Public routes @@ -77,6 +80,24 @@ export const routes: Routes = [ canActivate: [RoleGuard], data: { roles: ['finance', 'super_admin'] }, }, + { + path: 'finance/disbursements', + component: DisbursementPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, + { + path: 'finance/check-register', + component: CheckRegisterPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, + { + path: 'finance/church-profile', + component: ChurchProfilePageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, ] }, diff --git a/APP/src/app/app.ts b/APP/src/app/app.ts index 2e5e1f3..9b0daa8 100644 --- a/APP/src/app/app.ts +++ b/APP/src/app/app.ts @@ -2,6 +2,7 @@ import { Component, ViewEncapsulation } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterOutlet } from '@angular/router'; import { DialogModule } from '@progress/kendo-angular-dialog'; +import { ToastContainerComponent } from './core/components/toast-container/toast-container.component'; @Component({ selector: 'app-root', @@ -9,7 +10,8 @@ import { DialogModule } from '@progress/kendo-angular-dialog'; imports: [ CommonModule, RouterOutlet, - DialogModule + DialogModule, + ToastContainerComponent ], templateUrl: './app.html', styleUrls: ['./app.scss', '../styles.scss'], diff --git a/APP/src/app/core/components/toast-container/toast-container.component.scss b/APP/src/app/core/components/toast-container/toast-container.component.scss new file mode 100644 index 0000000..718d784 --- /dev/null +++ b/APP/src/app/core/components/toast-container/toast-container.component.scss @@ -0,0 +1,64 @@ +.toast-stack { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 11000; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 420px; + pointer-events: none; +} + +.toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 10px 14px; + border-radius: 8px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18); + color: #ffffff; + font-size: 0.875rem; + line-height: 1.35; + cursor: pointer; + animation: toast-in 0.18s ease-out; +} + +.toast-message { + flex: 1; + word-break: break-word; +} + +.toast-close { + background: transparent; + border: none; + color: inherit; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + opacity: 0.85; +} + +.toast-error { + background-color: #dc2626; +} + +.toast-success { + background-color: #16a34a; +} + +.toast-info { + background-color: #2563eb; +} + +@keyframes toast-in { + from { + transform: translateY(-6px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} diff --git a/APP/src/app/core/components/toast-container/toast-container.component.ts b/APP/src/app/core/components/toast-container/toast-container.component.ts new file mode 100644 index 0000000..fea0b4f --- /dev/null +++ b/APP/src/app/core/components/toast-container/toast-container.component.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ToastService } from '../../services/toast.service'; + +@Component({ + selector: 'app-toast-container', + standalone: true, + imports: [CommonModule], + template: ` +
+
+ {{ toast.message }} + +
+
+ `, + styleUrls: ['./toast-container.component.scss'], +}) +export class ToastContainerComponent { + constructor(public toastService: ToastService) {} +} diff --git a/APP/src/app/core/interceptors/http-error.interceptor.ts b/APP/src/app/core/interceptors/http-error.interceptor.ts new file mode 100644 index 0000000..299a35d --- /dev/null +++ b/APP/src/app/core/interceptors/http-error.interceptor.ts @@ -0,0 +1,94 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, from, map, Observable, of, switchMap, tap, throwError } from 'rxjs'; +import { ToastService } from '../services/toast.service'; + +/** + * Surfaces every failed HTTP response as a toast so errors are never silent. + * 401s are left to the auth interceptor (token refresh / redirect to login). + * + * Must be registered BEFORE the auth interceptor so its catchError runs LAST + * (outermost) — i.e. after auth has had a chance to refresh-and-retry a 401. + */ +export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => { + const toastService = inject(ToastService); + + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 401) { + // Handled by the auth interceptor (silent refresh or redirect to login). + return throwError(() => error); + } + + return resolveMessage(error).pipe( + tap(message => toastService.error(message)), + switchMap(() => throwError(() => error)), + ); + }), + ); +}; + +/** + * Builds a human-readable message from the error response. The body can be a + * parsed object, a string, or a Blob (when the request used responseType:'blob', + * e.g. PDF / signature downloads) — Blobs must be read asynchronously. + */ +function resolveMessage(error: HttpErrorResponse): Observable { + const body = error.error; + + if (body instanceof Blob) { + return from(body.text()).pipe(map(text => parseBodyText(text, error.status))); + } + if (typeof body === 'string') { + return of(parseBodyText(body, error.status)); + } + if (body && typeof body === 'object') { + const message = (body as { message?: unknown }).message; + const title = (body as { title?: unknown }).title; + if (typeof message === 'string' && message.length > 0) { + return of(message); + } + if (typeof title === 'string' && title.length > 0) { + return of(title); + } + } + return of(defaultMessage(error.status)); +} + +function parseBodyText(text: string, status: number): string { + if (text) { + try { + const parsed = JSON.parse(text) as { message?: unknown; title?: unknown }; + if (typeof parsed.message === 'string' && parsed.message.length > 0) { + return parsed.message; + } + if (typeof parsed.title === 'string' && parsed.title.length > 0) { + return parsed.title; + } + } catch { + // Not JSON — fall through to the status-based default. + } + } + return defaultMessage(status); +} + +function defaultMessage(status: number): string { + switch (status) { + case 0: + return 'Cannot reach the server / 無法連線到伺服器'; + case 400: + return 'Invalid request / 請求格式錯誤'; + case 403: + return 'You do not have permission for this action / 沒有權限執行此操作'; + case 404: + return 'The requested item was not found / 找不到要求的資料'; + case 409: + return 'This action conflicts with the current state / 操作與目前狀態衝突'; + case 422: + return 'The submitted data is invalid / 提交的資料無效'; + case 500: + return 'A server error occurred / 伺服器發生錯誤'; + default: + return `Request failed (HTTP ${status}) / 請求失敗 (HTTP ${status})`; + } +} diff --git a/APP/src/app/core/services/toast.service.ts b/APP/src/app/core/services/toast.service.ts new file mode 100644 index 0000000..a0d9697 --- /dev/null +++ b/APP/src/app/core/services/toast.service.ts @@ -0,0 +1,42 @@ +import { Injectable, signal } from '@angular/core'; + +export type ToastType = 'error' | 'success' | 'info'; + +export interface Toast { + id: number; + type: ToastType; + message: string; +} + +/** + * Lightweight, dependency-free toast/notification store. A single + * ToastContainerComponent (mounted in the app root) renders whatever this holds. + */ +@Injectable({ providedIn: 'root' }) +export class ToastService { + private counter = 0; + readonly toasts = signal([]); + + error(message: string): void { + this.show('error', message); + } + + success(message: string): void { + this.show('success', message); + } + + info(message: string): void { + this.show('info', message); + } + + dismiss(id: number): void { + this.toasts.update(list => list.filter(toast => toast.id !== id)); + } + + private show(type: ToastType, message: string): void { + const id = ++this.counter; + this.toasts.update(list => [...list, { id, type, message }]); + const autoDismissMs = type === 'error' ? 8000 : 4000; + setTimeout(() => this.dismiss(id), autoDismissMs); + } +} diff --git a/APP/src/app/features/disbursement/components/issue-check-dialog/issue-check-dialog.component.html b/APP/src/app/features/disbursement/components/issue-check-dialog/issue-check-dialog.component.html new file mode 100644 index 0000000..525d924 --- /dev/null +++ b/APP/src/app/features/disbursement/components/issue-check-dialog/issue-check-dialog.component.html @@ -0,0 +1,75 @@ + +
+ + + +
+
+ Check #{{ i + 1 }} — {{ f.group.payeeType }} · Total {{ f.group.totalAmount | currency }} +
+ +
+ + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + +
DateDescriptionAmount
{{ l.expenseDate }}{{ l.description }}{{ l.amount | currency }}
Total{{ f.group.totalAmount | currency }}
+
+
+ + + + + +
diff --git a/APP/src/app/features/disbursement/components/issue-check-dialog/issue-check-dialog.component.ts b/APP/src/app/features/disbursement/components/issue-check-dialog/issue-check-dialog.component.ts new file mode 100644 index 0000000..88830c6 --- /dev/null +++ b/APP/src/app/features/disbursement/components/issue-check-dialog/issue-check-dialog.component.ts @@ -0,0 +1,83 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { DateInputsModule } from '@progress/kendo-angular-dateinputs'; +import { GridModule } from '@progress/kendo-angular-grid'; +import { PayeeGroupDto, IssueChecksRequest, PayeeCheckInstruction } from '../../models/disbursement.model'; + +interface PayeeForm { + group: PayeeGroupDto; + payeeName: string; + address: string; + city: string; + state: string; + zip: string; + checkNumber: string; + defaultCheckNumber: string; + memo: string; +} + +@Component({ + selector: 'app-issue-check-dialog', + standalone: true, + imports: [CommonModule, FormsModule, DialogsModule, ButtonsModule, InputsModule, DateInputsModule, GridModule], + templateUrl: './issue-check-dialog.component.html', +}) +export class IssueCheckDialogComponent implements OnInit { + /** The payee groups the user selected to pay. */ + @Input() groups: PayeeGroupDto[] = []; + /** Next sequential check number from the church profile, used to prefill. */ + @Input() nextCheckNumber = 1001; + + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + checkDate = new Date(); + forms: PayeeForm[] = []; + + ngOnInit(): void { + let n = this.nextCheckNumber; + this.forms = this.groups.map(g => { + const cn = String(n++); + return { + group: g, + payeeName: g.payeeName, + address: g.address ?? '', + city: g.city ?? '', + state: g.state ?? '', + zip: g.zip ?? '', + checkNumber: cn, + defaultCheckNumber: cn, + memo: '', + }; + }); + } + + confirm(): void { + const d = this.checkDate; + const checkDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + + const payees: PayeeCheckInstruction[] = this.forms.map(f => ({ + payeeType: f.group.payeeType, + memberId: f.group.memberId, + vendorKey: f.group.vendorKey, + payeeName: f.payeeName.trim(), + address: f.address.trim() || null, + city: f.city.trim() || null, + state: f.state.trim() || null, + zip: f.zip.trim() || null, + // Only send an override when the user changed the prefilled sequential number, + // so the server's auto-allocation (and counter consumption) stays in sync. + checkNumberOverride: f.checkNumber.trim() !== f.defaultCheckNumber ? f.checkNumber.trim() : null, + memo: f.memo.trim() || null, + expenseIds: f.group.lines.map(l => l.expenseId), + })); + + this.save.emit({ checkDate, payees }); + } + + onClose(): void { this.cancel.emit(); } +} diff --git a/APP/src/app/features/disbursement/components/receipt-sign-dialog/receipt-sign-dialog.component.html b/APP/src/app/features/disbursement/components/receipt-sign-dialog/receipt-sign-dialog.component.html new file mode 100644 index 0000000..a0a0d06 --- /dev/null +++ b/APP/src/app/features/disbursement/components/receipt-sign-dialog/receipt-sign-dialog.component.html @@ -0,0 +1,31 @@ + +
+
+ Check #{{ check.checkNumber }} · {{ check.payeeName }} · {{ check.amount | currency }} +
+ + + +
+ Signature / 簽名 + + + +
+
+ + + + + +
diff --git a/APP/src/app/features/disbursement/components/receipt-sign-dialog/receipt-sign-dialog.component.ts b/APP/src/app/features/disbursement/components/receipt-sign-dialog/receipt-sign-dialog.component.ts new file mode 100644 index 0000000..eca6142 --- /dev/null +++ b/APP/src/app/features/disbursement/components/receipt-sign-dialog/receipt-sign-dialog.component.ts @@ -0,0 +1,87 @@ +import { AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { CheckListItemDto } from '../../models/disbursement.model'; + +export interface ReceiptSignResult { signature: Blob; signedName: string; } + +@Component({ + selector: 'app-receipt-sign-dialog', + standalone: true, + imports: [CommonModule, FormsModule, DialogsModule, ButtonsModule, InputsModule], + templateUrl: './receipt-sign-dialog.component.html', +}) +export class ReceiptSignDialogComponent implements AfterViewInit { + @Input() check!: CheckListItemDto; + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + @ViewChild('pad', { static: false }) padRef!: ElementRef; + + signedName = ''; + hasInk = false; + submitting = false; + + private ctx!: CanvasRenderingContext2D; + private drawing = false; + + ngAfterViewInit(): void { + const canvas = this.padRef.nativeElement; + this.signedName = this.check.payeeName || ''; + this.ctx = canvas.getContext('2d')!; + this.ctx.fillStyle = '#ffffff'; + this.ctx.fillRect(0, 0, canvas.width, canvas.height); + this.ctx.strokeStyle = '#111827'; + this.ctx.lineWidth = 2; + this.ctx.lineCap = 'round'; + this.ctx.lineJoin = 'round'; + } + + private point(e: PointerEvent): { x: number; y: number } { + const rect = this.padRef.nativeElement.getBoundingClientRect(); + return { + x: (e.clientX - rect.left) * (this.padRef.nativeElement.width / rect.width), + y: (e.clientY - rect.top) * (this.padRef.nativeElement.height / rect.height), + }; + } + + onDown(e: PointerEvent): void { + e.preventDefault(); + this.drawing = true; + const p = this.point(e); + this.ctx.beginPath(); + this.ctx.moveTo(p.x, p.y); + this.padRef.nativeElement.setPointerCapture(e.pointerId); + } + + onMove(e: PointerEvent): void { + if (!this.drawing) return; + const p = this.point(e); + this.ctx.lineTo(p.x, p.y); + this.ctx.stroke(); + this.hasInk = true; + } + + onUp(): void { this.drawing = false; } + + clear(): void { + const canvas = this.padRef.nativeElement; + this.ctx.fillStyle = '#ffffff'; + this.ctx.fillRect(0, 0, canvas.width, canvas.height); + this.hasInk = false; + } + + confirm(): void { + if (!this.hasInk || !this.signedName.trim() || this.submitting) return; + this.submitting = true; + this.padRef.nativeElement.toBlob(blob => { + if (!blob) { this.submitting = false; return; } + this.save.emit({ signature: blob, signedName: this.signedName.trim() }); + }, 'image/png'); + } + + onClose(): void { this.cancel.emit(); } +} diff --git a/APP/src/app/features/disbursement/models/disbursement.model.ts b/APP/src/app/features/disbursement/models/disbursement.model.ts new file mode 100644 index 0000000..207044f --- /dev/null +++ b/APP/src/app/features/disbursement/models/disbursement.model.ts @@ -0,0 +1,53 @@ +export type PayeeType = 'Vendor' | 'Member'; +export type CheckStatus = 'Issued' | 'Voided'; + +export interface PagedResult { + items: T[]; totalCount: number; page: number; pageSize: number; totalPages: number; +} + +export interface ExpenseLineDto { + expenseId: number; expenseDate: string; description: string; amount: number; + ministryName: string; categoryName: string; +} + +export interface PayeeGroupDto { + payeeType: PayeeType; memberId: number | null; vendorKey: string | null; + payeeName: string; address: string | null; city: string | null; + state: string | null; zip: string | null; + totalAmount: number; lines: ExpenseLineDto[]; +} + +export interface PayeeCheckInstruction { + payeeType: PayeeType; memberId: number | null; vendorKey: string | null; + payeeName: string; address: string | null; city: string | null; + state: string | null; zip: string | null; + checkNumberOverride: string | null; memo: string | null; expenseIds: number[]; +} + +export interface IssueChecksRequest { checkDate: string; payees: PayeeCheckInstruction[]; } + +export interface IssuedCheckDto { checkId: number; checkNumber: string; payeeName: string; amount: number; } +export interface IssueChecksResultDto { created: IssuedCheckDto[]; } + +export interface CheckListItemDto { + id: number; checkNumber: string; checkDate: string; amount: number; + payeeType: PayeeType; payeeName: string; status: CheckStatus; lineCount: number; + signed: boolean; receiptSignedName: string | null; receiptSignedAt: string | null; +} + +export interface CheckLineDto { expenseId: number; description: string; amount: number; } + +export interface CheckDetailDto extends CheckListItemDto { + memberId: number | null; address: string | null; city: string | null; + state: string | null; zip: string | null; memo: string | null; + voidReason: string | null; voidedAt: string | null; issuedAt: string; + lines: CheckLineDto[]; +} + +export interface ChurchProfileDto { + id: number; name: string; address: string | null; city: string | null; + state: string | null; zipCode: string | null; bankName: string | null; + bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number; +} + +export type UpdateChurchProfileRequest = Omit; diff --git a/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.html b/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.html new file mode 100644 index 0000000..4f88fd2 --- /dev/null +++ b/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.html @@ -0,0 +1,120 @@ +
+ + +
+ + + +
+ + + + + + + + + {{ dataItem.lineCount }} + + + + {{ dataItem.status }} + + + + + + Signed +
+ {{ dataItem.receiptSignedName }} · {{ dataItem.receiptSignedAt | date:'short' }} +
+
+ +
+
+ + + + + + + + + + + +
+ + + +
+
+
Payee: {{ detail.payeeName }}
+
Date: {{ detail.checkDate }}
+
Amount: {{ detail.amount | currency }}
+
Status: {{ detail.status }}
+
Memo: {{ detail.memo }}
+
+ Signed: {{ detail.receiptSignedName }} · {{ detail.receiptSignedAt | date:'short' }} +
+
+ Voided: {{ detail.voidReason }} · {{ detail.voidedAt | date:'short' }} +
+
+ + + + + + + + + + + + + +
DescriptionAmount
{{ l.description }}{{ l.amount | currency }}
+
+ + + +
+ + + +
+

+ Voiding returns the bundled expenses to Approved so they can be re-issued. + / 作廢將使支出退回「已核准」可重新開立。 +

+ +
+ + + + +
+ + + + +
\ No newline at end of file diff --git a/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.scss b/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.scss new file mode 100644 index 0000000..7741d97 --- /dev/null +++ b/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.scss @@ -0,0 +1,26 @@ +%badge-base { + display: inline-block; + padding: 2px 10px; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} + +.badge-approved { + @extend %badge-base; + background-color: #dbeafe; + color: #1e40af; +} + +.badge-paid { + @extend %badge-base; + background-color: #d1fae5; + color: #065f46; +} + +.badge-rejected { + @extend %badge-base; + background-color: #fee2e2; + color: #991b1b; +} diff --git a/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.ts b/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.ts new file mode 100644 index 0000000..595e8a3 --- /dev/null +++ b/APP/src/app/features/disbursement/pages/check-register-page/check-register-page.component.ts @@ -0,0 +1,107 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { CHECK_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists'; +import { DisbursementApiService, CheckRegisterQuery } from '../../services/disbursement-api.service'; +import { ReceiptSignDialogComponent, ReceiptSignResult } from '../../components/receipt-sign-dialog/receipt-sign-dialog.component'; +import { CheckListItemDto, CheckDetailDto } from '../../models/disbursement.model'; + +@Component({ + selector: 'app-check-register-page', + standalone: true, + imports: [ + CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule, + InputsModule, DialogsModule, ReceiptSignDialogComponent, + ], + templateUrl: './check-register-page.component.html', + styleUrls: ['./check-register-page.component.scss'], +}) +export class CheckRegisterPageComponent implements OnInit { + rows: CheckListItemDto[] = []; + total = 0; + page = 1; + pageSize = 20; + loading = false; + + readonly statuses = CHECK_STATUS_OPTIONS; + filter: CheckRegisterQuery = {}; + + detail: CheckDetailDto | null = null; + signRow: CheckListItemDto | null = null; + voidRow: CheckListItemDto | null = null; + voidReason = ''; + + constructor(private api: DisbursementApiService) {} + + ngOnInit(): void { this.load(); } + + load(): void { + this.loading = true; + this.api.getRegister({ ...this.filter, page: this.page, pageSize: this.pageSize }).subscribe({ + next: r => { this.rows = r.items; this.total = r.totalCount; this.loading = false; }, + error: () => (this.loading = false), + }); + } + + get skip(): number { return (this.page - 1) * this.pageSize; } + applyFilter(): void { this.page = 1; this.load(); } + onPageChange(e: PageChangeEvent): void { this.page = Math.floor(e.skip / this.pageSize) + 1; this.load(); } + + view(row: CheckListItemDto): void { + this.api.getCheck(row.id).subscribe(d => (this.detail = d)); + } + + print(row: CheckListItemDto): void { + this.api.downloadCheckPdf(row.id).subscribe(blob => this.openBlob(blob)); + } + + viewSignature(row: CheckListItemDto): void { + this.api.getSignature(row.id).subscribe(blob => this.openBlob(blob)); + } + + printReceipt(row: CheckListItemDto): void { + this.api.downloadReceiptPdf(row.id).subscribe(blob => this.openBlob(blob)); + } + + openSign(row: CheckListItemDto): void { this.signRow = row; } + + onSign(result: ReceiptSignResult): void { + if (!this.signRow) return; + this.api.acknowledge(this.signRow.id, result.signature, result.signedName).subscribe({ + next: () => { this.signRow = null; this.load(); }, + error: () => { + // Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry. + }, + }); + } + + openVoid(row: CheckListItemDto): void { this.voidRow = row; this.voidReason = ''; } + + confirmVoid(): void { + if (!this.voidRow) return; + this.api.voidCheck(this.voidRow.id, this.voidReason || null).subscribe({ + next: () => { this.voidRow = null; this.load(); }, + error: () => { + // Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry. + }, + }); + } + + canSign(row: CheckListItemDto): boolean { return row.status === 'Issued' && !row.signed; } + canVoid(row: CheckListItemDto): boolean { return row.status === 'Issued'; } + + statusClass(status: string): string { + return ({ Issued: 'badge-approved', Voided: 'badge-rejected' } as Record)[status] ?? ''; + } + + private openBlob(blob: Blob): void { + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } +} diff --git a/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html b/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html new file mode 100644 index 0000000..2b04620 --- /dev/null +++ b/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html @@ -0,0 +1,53 @@ +
+ + +
+
+ + + +
+ + +
+ + + + +
+ +
+ + {{ savedMsg }} +
+
+
diff --git a/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts b/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts new file mode 100644 index 0000000..abfcb65 --- /dev/null +++ b/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts @@ -0,0 +1,39 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { DisbursementApiService } from '../../services/disbursement-api.service'; +import { ChurchProfileDto } from '../../models/disbursement.model'; + +@Component({ + selector: 'app-church-profile-page', + standalone: true, + imports: [CommonModule, FormsModule, ButtonsModule, InputsModule], + templateUrl: './church-profile-page.component.html', +}) +export class ChurchProfilePageComponent implements OnInit { + model: ChurchProfileDto | null = null; + saving = false; + savedMsg = ''; + + constructor(private api: DisbursementApiService) {} + + ngOnInit(): void { + this.api.getChurchProfile().subscribe(p => (this.model = p)); + } + + save(): void { + if (!this.model || this.saving) return; + this.saving = true; + this.savedMsg = ''; + const { id, ...req } = this.model; + this.api.updateChurchProfile(req).subscribe({ + next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; }, + error: () => { + // Error message is shown globally by httpErrorInterceptor. + this.saving = false; + }, + }); + } +} diff --git a/APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.html b/APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.html new file mode 100644 index 0000000..2feff3c --- /dev/null +++ b/APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.html @@ -0,0 +1,59 @@ +
+ + +

+ Approved expenses awaiting payment, grouped by payee. Select payees and issue one check each. + / 已核准待付款支出,依收款人彙整,每位收款人開立一張支票。 +

+ + + + + + + + + {{ dataItem.lines.length }} + + + + + + + + + + + + + + + + + + + + +
DateDescriptionMinistryCategoryAmount
{{ l.expenseDate }}{{ l.description }}{{ l.ministryName }}{{ l.categoryName }}{{ l.amount | currency }}
+
+
+ + + +
diff --git a/APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.scss b/APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.scss new file mode 100644 index 0000000..ff30034 --- /dev/null +++ b/APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.scss @@ -0,0 +1,3 @@ +.page-header { + margin-bottom: 0.5rem; +} diff --git a/APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.ts b/APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.ts new file mode 100644 index 0000000..7b61a58 --- /dev/null +++ b/APP/src/app/features/disbursement/pages/disbursement-page/disbursement-page.component.ts @@ -0,0 +1,78 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule } from '@progress/kendo-angular-grid'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DisbursementApiService } from '../../services/disbursement-api.service'; +import { IssueCheckDialogComponent } from '../../components/issue-check-dialog/issue-check-dialog.component'; +import { PayeeGroupDto, IssueChecksRequest } from '../../models/disbursement.model'; + +interface PayeeRow extends PayeeGroupDto { key: string; } + +@Component({ + selector: 'app-disbursement-page', + standalone: true, + imports: [CommonModule, FormsModule, GridModule, ButtonsModule, IssueCheckDialogComponent], + templateUrl: './disbursement-page.component.html', + styleUrls: ['./disbursement-page.component.scss'], +}) +export class DisbursementPageComponent implements OnInit { + rows: PayeeRow[] = []; + selectedKeys: string[] = []; + loading = false; + nextCheckNumber = 1001; + + issueDialogGroups: PayeeGroupDto[] | null = null; + + constructor(private api: DisbursementApiService) {} + + ngOnInit(): void { + this.api.getChurchProfile().subscribe(p => (this.nextCheckNumber = p.nextCheckNumber)); + this.load(); + } + + load(): void { + this.loading = true; + this.selectedKeys = []; + this.api.getApprovedUnpaid().subscribe({ + next: groups => { + this.rows = groups.map(g => ({ ...g, key: this.keyOf(g) })); + this.loading = false; + }, + error: () => (this.loading = false), + }); + } + + private keyOf(g: PayeeGroupDto): string { + return `${g.payeeType}:${g.payeeType === 'Member' ? g.memberId : g.vendorKey}`; + } + + get selectedCount(): number { return this.selectedKeys.length; } + + openIssue(): void { + const selected = this.rows.filter(r => this.selectedKeys.includes(r.key)); + if (selected.length === 0) return; + this.issueDialogGroups = selected; + } + + onIssue(req: IssueChecksRequest): void { + this.api.issue(req).subscribe({ + next: result => { + this.issueDialogGroups = null; + result.created.forEach(c => this.openPdf(c.checkId)); + this.load(); + }, + error: () => { + // Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry. + }, + }); + } + + private openPdf(checkId: number): void { + this.api.downloadCheckPdf(checkId).subscribe(blob => { + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + }); + } +} diff --git a/APP/src/app/features/disbursement/services/disbursement-api.service.ts b/APP/src/app/features/disbursement/services/disbursement-api.service.ts new file mode 100644 index 0000000..c5de3db --- /dev/null +++ b/APP/src/app/features/disbursement/services/disbursement-api.service.ts @@ -0,0 +1,78 @@ +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 { + PagedResult, PayeeGroupDto, IssueChecksRequest, IssueChecksResultDto, + CheckListItemDto, CheckDetailDto, ChurchProfileDto, UpdateChurchProfileRequest, +} from '../models/disbursement.model'; + +export interface CheckRegisterQuery { + page?: number; pageSize?: number; status?: string; search?: string; from?: string; to?: string; +} + +@Injectable({ providedIn: 'root' }) +export class DisbursementApiService { + private readonly endpoint: string; + private readonly profileEndpoint: string; + + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('disbursements'); + this.profileEndpoint = apiConfig.getApiUrl('church-profile'); + } + + private toParams(q: Record): HttpParams { + let p = new HttpParams(); + for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== '') p = p.set(k, String(v)); + return p; + } + + getApprovedUnpaid(): Observable { + return this.http.get(`${this.endpoint}/approved-unpaid`); + } + + issue(req: IssueChecksRequest): Observable { + return this.http.post(`${this.endpoint}/issue`, req); + } + + getRegister(q: CheckRegisterQuery): Observable> { + return this.http.get>(`${this.endpoint}/checks`, { params: this.toParams(q as Record) }); + } + + getCheck(id: number): Observable { + return this.http.get(`${this.endpoint}/checks/${id}`); + } + + voidCheck(id: number, reason: string | null): Observable { + return this.http.post(`${this.endpoint}/checks/${id}/void`, { reason }); + } + + /** Blob fetch via HttpClient so the auth interceptor attaches the JWT (a raw window.open would 401). */ + downloadCheckPdf(id: number): Observable { + return this.http.get(`${this.endpoint}/checks/${id}/pdf`, { responseType: 'blob' }); + } + + /** Signed receipt PDF (check info + disbursement detail + e-signature). */ + downloadReceiptPdf(id: number): Observable { + return this.http.get(`${this.endpoint}/checks/${id}/receipt-pdf`, { responseType: 'blob' }); + } + + acknowledge(id: number, signature: Blob, signedName: string): Observable { + const form = new FormData(); + form.append('signature', signature, `check-${id}-signature.png`); + form.append('signedName', signedName); + return this.http.post(`${this.endpoint}/checks/${id}/acknowledge`, form); + } + + getSignature(id: number): Observable { + return this.http.get(`${this.endpoint}/checks/${id}/signature`, { responseType: 'blob' }); + } + + getChurchProfile(): Observable { + return this.http.get(this.profileEndpoint); + } + + updateChurchProfile(r: UpdateChurchProfileRequest): Observable { + return this.http.put(this.profileEndpoint, r); + } +} diff --git a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts index afef898..563c1bf 100644 --- a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts +++ b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts @@ -12,6 +12,7 @@ import { MemberApiService } from '../../../members/services/member-api.service'; import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model'; import { MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest, + ExpenseListItemDto, } from '../../models/expense.model'; export interface ExpenseFormResult { request: CreateExpenseRequest; receipt: File | null; } @@ -29,6 +30,8 @@ export class ExpenseFormDialogComponent implements OnInit { @Input() mode: 'vendor' | 'reimbursement' = 'reimbursement'; @Input() allowMemberPick = false; @Input() title = 'New Expense'; + /** When set, the dialog prefills from this row for editing instead of starting blank. */ + @Input() expense: ExpenseListItemDto | null = null; @Output() save = new EventEmitter(); @Output() cancel = new EventEmitter(); @@ -59,7 +62,30 @@ export class ExpenseFormDialogComponent implements OnInit { ngOnInit(): void { this.ministryApi.getAll().subscribe(m => (this.ministries = m)); - this.catApi.getAll(false).subscribe(g => (this.groups = g)); + this.catApi.getAll(false).subscribe(groups => { + this.groups = groups; + // Populate the sub-category list for the prefilled group so its value displays on edit. + if (this.expense) { + this.subs = this.groups.find(group => group.id === this.expense!.categoryGroupId)?.subCategories ?? []; + } + }); + if (this.expense) this.prefill(this.expense); + } + + private prefill(expense: ExpenseListItemDto): void { + // expenseDate is a "yyyy-MM-dd" string; build a local Date to avoid a timezone day-shift. + const [year, month, day] = expense.expenseDate.split('-').map(Number); + this.form = { + ministryId: expense.ministryId, + categoryGroupId: expense.categoryGroupId, + subCategoryId: expense.subCategoryId, + amount: expense.amount, + description: expense.description, + vendorName: expense.vendorName ?? '', + checkNumber: expense.checkNumber ?? '', + memberId: expense.memberId, + expenseDate: new Date(year, month - 1, day), + }; } onGroupChange(groupId: number | null): void { diff --git a/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html index 2067eaf..a6a5e3d 100644 --- a/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html +++ b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html @@ -11,21 +11,18 @@

Groups / 組別

- +
Click a row to view its subcategories · right-click for actions
+ {{ g.isActive ? 'Yes' : 'No' }} - - - - - - - + @@ -35,20 +32,17 @@
Select a group on the left to view its subcategories.
- +
Right-click a row for actions
+ {{ s.isActive ? 'Yes' : 'No' }} - - - - - - + diff --git a/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.scss b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.scss index 0bc81e8..9151205 100644 --- a/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.scss +++ b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.scss @@ -28,3 +28,18 @@ color: #888; font-style: italic; } + +.hint-text-sm { + margin-bottom: 0.5rem; + font-size: 0.8rem; + color: #999; +} + +// Group grid: rows are clickable to select. +.clickable-rows ::ng-deep .k-grid-content tr { + cursor: pointer; +} + +::ng-deep .k-grid .k-table-row.selected-row > td { + background-color: rgba(0, 105, 217, 0.12); +} diff --git a/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.ts b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.ts index d309cde..994fc51 100644 --- a/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.ts +++ b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.ts @@ -1,17 +1,18 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { GridModule } from '@progress/kendo-angular-grid'; +import { GridModule, CellClickEvent, RowClassArgs } from '@progress/kendo-angular-grid'; import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { DialogsModule } from '@progress/kendo-angular-dialog'; import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu'; import { ExpenseCategoryApiService } from '../../services/expense-category-api.service'; import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model'; @Component({ selector: 'app-expense-categories-page', standalone: true, - imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule], + imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, ContextMenuModule], templateUrl: './expense-categories-page.component.html', styleUrls: ['./expense-categories-page.component.scss'], }) @@ -20,6 +21,13 @@ export class ExpenseCategoriesPageComponent implements OnInit { selectedGroup: ExpenseCategoryGroupDto | null = null; loading = false; + @ViewChild('groupMenu') groupMenu!: ContextMenuComponent; + @ViewChild('subMenu') subMenu!: ContextMenuComponent; + groupMenuItems: { text: string }[] = []; + subMenuItems: { text: string }[] = []; + private contextGroup: ExpenseCategoryGroupDto | null = null; + private contextSub: ExpenseSubCategoryDto | null = null; + groupDialogOpen = false; editingGroupId: number | null = null; groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true }; @@ -47,6 +55,50 @@ export class ExpenseCategoriesPageComponent implements OnInit { selectGroup(g: ExpenseCategoryGroupDto): void { this.selectedGroup = g; } get subCategories(): ExpenseSubCategoryDto[] { return this.selectedGroup?.subCategories ?? []; } + // Highlight the currently selected group row. + groupRowClass = (context: RowClassArgs): Record => { + return { 'selected-row': this.selectedGroup?.id === context.dataItem.id }; + }; + + // Left-click selects the group (reveals its subcategories); right-click opens the actions menu. + onGroupCellClick(event: CellClickEvent): void { + if (event.type === 'contextmenu') { + event.originalEvent.preventDefault(); + this.contextGroup = event.dataItem; + this.groupMenuItems = this.buildMenuItems(event.dataItem.isActive); + this.groupMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY }); + } else { + this.selectGroup(event.dataItem); + } + } + + onGroupMenuSelect(event: ContextMenuSelectEvent): void { + if (!this.contextGroup) return; + if (event.item.text === 'Edit') this.openEditGroup(this.contextGroup); + else if (event.item.text === 'Deactivate') this.deactivateGroup(this.contextGroup); + } + + // Subcategory rows have no selection behaviour; only the right-click actions menu. + onSubCellClick(event: CellClickEvent): void { + if (event.type !== 'contextmenu') return; + event.originalEvent.preventDefault(); + this.contextSub = event.dataItem; + this.subMenuItems = this.buildMenuItems(event.dataItem.isActive); + this.subMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY }); + } + + onSubMenuSelect(event: ContextMenuSelectEvent): void { + if (!this.contextSub) return; + if (event.item.text === 'Edit') this.openEditSub(this.contextSub); + else if (event.item.text === 'Deactivate') this.deactivateSub(this.contextSub); + } + + private buildMenuItems(isActive: boolean): { text: string }[] { + const items: { text: string }[] = [{ text: 'Edit' }]; + if (isActive) items.push({ text: 'Deactivate' }); + return items; + } + openNewGroup(): void { this.editingGroupId = null; this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true }; diff --git a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html index 2fe091c..3444379 100644 --- a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html +++ b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html @@ -7,33 +7,22 @@
@@ -46,28 +35,23 @@
- + - + - - + - + {{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }} + {{ dataItem.vendorName || dataItem.memberName || '—' }} @@ -88,7 +72,7 @@ - + @@ -96,30 +80,21 @@ - + - - + @@ -154,4 +129,4 @@ - + \ No newline at end of file diff --git a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts index c8065b4..318d3b8 100644 --- a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts +++ b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts @@ -46,7 +46,7 @@ export class ExpensesPageComponent implements OnInit { rejectRow: ExpenseListItemDto | null = null; rejectNotes = ''; - constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) {} + constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) { } ngOnInit(): void { this.ministryApi.getAll().subscribe(m => (this.ministries = m)); @@ -124,7 +124,11 @@ export class ExpensesPageComponent implements OnInit { } canApproveOrReject(row: ExpenseListItemDto): boolean { return row.status === 'PendingApproval'; } - canPay(row: ExpenseListItemDto): boolean { return row.status === 'Approved'; } + canPay(row: ExpenseListItemDto): boolean { + return false; + // row.status === 'Approved'; + //should be pay by disbursement + } statusClass(status: string): string { return ({ diff --git a/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.html b/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.html index 6e44d17..da14488 100644 --- a/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.html +++ b/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.html @@ -22,6 +22,7 @@ + @@ -34,8 +35,9 @@ + (cancel)="closeDialog()"> diff --git a/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.ts b/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.ts index 3338edb..c148a54 100644 --- a/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.ts +++ b/APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.ts @@ -18,6 +18,7 @@ export class MyReimbursementsPageComponent implements OnInit { rows: ExpenseListItemDto[] = []; loading = false; dialogOpen = false; + editRow: ExpenseListItemDto | null = null; constructor(private api: ExpenseApiService) {} @@ -31,14 +32,23 @@ export class MyReimbursementsPageComponent implements OnInit { }); } - openNew(): void { this.dialogOpen = true; } + openNew(): void { this.editRow = null; this.dialogOpen = true; } + openEdit(row: ExpenseListItemDto): void { this.editRow = row; this.dialogOpen = true; } + closeDialog(): void { this.dialogOpen = false; this.editRow = null; } onSave(result: ExpenseFormResult): void { - this.api.create(result.request).pipe( - switchMap(created => result.receipt - ? this.api.uploadReceipt(created.id, result.receipt).pipe(switchMap(() => of(created))) - : of(created)), - ).subscribe(() => { this.dialogOpen = false; this.load(); }); + if (this.editRow) { + const id = this.editRow.id; + this.api.update(id, result.request).pipe( + switchMap(() => result.receipt ? this.api.uploadReceipt(id, result.receipt) : of(void 0)), + ).subscribe(() => { this.closeDialog(); this.load(); }); + } else { + this.api.create(result.request).pipe( + switchMap(created => result.receipt + ? this.api.uploadReceipt(created.id, result.receipt).pipe(switchMap(() => of(created))) + : of(created)), + ).subscribe(() => { this.closeDialog(); this.load(); }); + } } submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); } diff --git a/APP/src/app/portals/user-portal/user-portal.component.ts b/APP/src/app/portals/user-portal/user-portal.component.ts index db58af9..9182ef5 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.ts +++ b/APP/src/app/portals/user-portal/user-portal.component.ts @@ -96,7 +96,10 @@ export class UserPortalComponent implements OnInit, OnDestroy { { text: 'Giving Types', icon: categorizeIcon, path: '/user-portal/finance/giving-categories' }, { text: 'Expenses', icon: moneyExchangeIcon, path: '/user-portal/finance/expenses' }, { text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories' }, + { text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements' }, + { text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register' }, { text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement' }, + { text: 'Church Profile', icon: buildingsOutlineIcon, path: '/user-portal/finance/church-profile' }, ]; public personalNavItems: NavItem[] = [ @@ -226,7 +229,10 @@ export class UserPortalComponent implements OnInit, OnDestroy { 'reimbursements': 'My Reimbursements', 'finance/expenses': 'Expenses', 'finance/expense-categories': 'Expense Categories', + 'finance/disbursements': 'Disbursement Management', + 'finance/check-register': 'Check Register', 'finance/monthly-statement': 'Monthly Statement', + 'finance/church-profile': 'Church Profile', }; return titles[page] ?? 'Dashboard'; } diff --git a/APP/src/app/shared/i18n/option-lists.ts b/APP/src/app/shared/i18n/option-lists.ts index 9a63166..00f6de7 100644 --- a/APP/src/app/shared/i18n/option-lists.ts +++ b/APP/src/app/shared/i18n/option-lists.ts @@ -22,6 +22,11 @@ export const EXPENSE_STATUS_OPTIONS: readonly BilingualOption[] = [ { value: 'Rejected', label: 'Rejected/已拒絕' }, ]; +export const CHECK_STATUS_OPTIONS: readonly BilingualOption[] = [ + { value: 'Issued', label: 'Issued/已開立' }, + { value: 'Voided', label: 'Voided/已作廢' }, +]; + export const MEMBER_STATUS_OPTIONS: readonly BilingualOption[] = [ { value: 'Member', label: 'Member/會友' }, { value: 'Visitor', label: 'Visitor/訪客' },