Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa3e75a333 | |||
| 8bdb942a49 | |||
| 609ce6a439 | |||
| 46a4298a71 | |||
| 9f91683633 | |||
| 5aaac3246d | |||
| 677cb8f054 | |||
| f79dab163d | |||
| 4438c351e2 | |||
| 1a03a1cbba | |||
| 3f61e9ceaf | |||
| b41297f972 | |||
| a5de2dbbb1 | |||
| 1fa36ae62f | |||
| 1353b5571f | |||
| 4e83f27703 | |||
| d5e1732505 | |||
| ae757bee3d | |||
| 6e04b64466 | |||
| f70a7b5a58 | |||
| b6b110254a | |||
| d3e6b5aed5 | |||
| ac84097254 | |||
| 971bf165cc | |||
| f1faa0d435 | |||
| 9dbb1d38d8 | |||
| e908e35530 | |||
| b51f22cfba | |||
| 764464e785 | |||
| cfd344f48c | |||
| 4dc7ff7df7 | |||
| e9aad74df6 | |||
| e768f53ccc | |||
| b0e2e112fc | |||
| 28eba8a3ea | |||
| 7eb6a4db78 | |||
| 7dc03f3bc0 | |||
| 8d91bbeb31 | |||
| 182f8bf74c | |||
| a88567fea6 | |||
| e53cea7a82 | |||
| e88ea7917f | |||
| 99585a1c0e | |||
| d327a5146c | |||
| 4276ca890b |
@@ -169,6 +169,48 @@ public class AuthServiceTests
|
|||||||
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
|
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_LinkedMember_ReturnsMemberInfo()
|
||||||
|
{
|
||||||
|
var db = BuildDb();
|
||||||
|
db.Members.Add(new Member
|
||||||
|
{
|
||||||
|
Id = 7,
|
||||||
|
NickName = "Johnny",
|
||||||
|
FirstName_en = "John",
|
||||||
|
LastName_en = "Chen",
|
||||||
|
LastName_zh = "陳",
|
||||||
|
CreatedBy = "seed",
|
||||||
|
UpdatedBy = "seed",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = 7 };
|
||||||
|
var um = BuildUserManager(findResult: user);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, db);
|
||||||
|
|
||||||
|
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||||
|
|
||||||
|
Assert.NotNull(response.User.MemberInfo);
|
||||||
|
Assert.Equal(7, response.User.MemberInfo!.Id);
|
||||||
|
Assert.Equal("Johnny", response.User.MemberInfo.NickName);
|
||||||
|
Assert.Equal("Chen", response.User.MemberInfo.LastName_en);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_AdminOnlyAccount_ReturnsNullMemberInfo()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = null };
|
||||||
|
var um = BuildUserManager(findResult: user);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, BuildDb());
|
||||||
|
|
||||||
|
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||||
|
|
||||||
|
Assert.Null(response.User.MemberInfo);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Refresh tests
|
// Refresh tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class DbSeederForm990Tests
|
||||||
|
{
|
||||||
|
private static AppDbContext BuildDb()
|
||||||
|
{
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "seed") })) };
|
||||||
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedExpenseCategories_AddsNewGroups_RenamesDuplicates_AndIsIdempotent()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var fnb = new ExpenseCategoryGroup { Name_en = "Food & Beverage", Name_zh = "餐飲", SortOrder = 3 };
|
||||||
|
db.ExpenseCategoryGroups.Add(fnb);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { GroupId = fnb.Id, Name_en = "Consumables", Name_zh = "消耗品" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db); // idempotent second run
|
||||||
|
|
||||||
|
var groups = await db.ExpenseCategoryGroups.ToListAsync();
|
||||||
|
Assert.Contains(groups, g => g.Name_en == "Professional Services");
|
||||||
|
Assert.Contains(groups, g => g.Name_en == "Information Technology");
|
||||||
|
Assert.Contains(groups, g => g.Name_en == "Finance & Banking");
|
||||||
|
|
||||||
|
var fnbSubs = await db.ExpenseSubCategories.Where(s => s.GroupId == fnb.Id).ToListAsync();
|
||||||
|
Assert.DoesNotContain(fnbSubs, s => s.Name_en == "Consumables");
|
||||||
|
Assert.Contains(fnbSubs, s => s.Name_en == "Disposable Tableware");
|
||||||
|
|
||||||
|
Assert.Single(groups, g => g.Name_en == "Professional Services");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedMinistries_SetsAdministrationToManagementGeneral_OthersProgram()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await DbSeeder.SeedMinistriesAsync(db);
|
||||||
|
|
||||||
|
var admin = await db.Ministries.FirstAsync(m => m.Name_en == "Administration");
|
||||||
|
var worship = await db.Ministries.FirstAsync(m => m.Name_en == "Worship");
|
||||||
|
Assert.Equal("ManagementGeneral", admin.DefaultFunctionalClass);
|
||||||
|
Assert.Equal("Program", worship.DefaultFunctionalClass);
|
||||||
|
|
||||||
|
// Activity/shepherding ministries are an attribution axis only; they default to Program
|
||||||
|
// so adding them never distorts the 990 functional columns.
|
||||||
|
var cellGroups = await db.Ministries.FirstAsync(m => m.Name_en == "Cell Groups");
|
||||||
|
var specialEvents = await db.Ministries.FirstAsync(m => m.Name_en == "Special Events");
|
||||||
|
Assert.Equal("Program", cellGroups.DefaultFunctionalClass);
|
||||||
|
Assert.Equal("Program", specialEvents.DefaultFunctionalClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedForm990Lines_CreatesCatalog_AndMapsKnownSubcategories()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db); // idempotent
|
||||||
|
|
||||||
|
Assert.Equal(1, await db.Form990ExpenseLines.CountAsync(l => l.LineCode == "7"));
|
||||||
|
Assert.True(await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == "24"));
|
||||||
|
|
||||||
|
var salary = await db.ExpenseSubCategories.Include(s => s.Form990Line)
|
||||||
|
.FirstAsync(s => s.Name_en == "Salary & Wages");
|
||||||
|
Assert.Equal("7", salary.Form990Line!.LineCode);
|
||||||
|
|
||||||
|
var audit = await db.ExpenseSubCategories.Include(s => s.Form990Line)
|
||||||
|
.FirstAsync(s => s.Name_en == "Accounting & Audit");
|
||||||
|
Assert.Equal("11c", audit.Form990Line!.LineCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedForm990Lines_MapsAuditCorrectedSubcategories_OffTheLine24CatchAll()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||||
|
|
||||||
|
async Task<string> CodeOf(string subEn) =>
|
||||||
|
(await db.ExpenseSubCategories.Include(s => s.Form990Line)
|
||||||
|
.FirstAsync(s => s.Name_en == subEn)).Form990Line!.LineCode;
|
||||||
|
|
||||||
|
// Newly mapped subcategories that previously fell through to line 24.
|
||||||
|
Assert.Equal("13", await CodeOf("Bank & Processing Fees"));
|
||||||
|
Assert.Equal("13", await CodeOf("Rental"));
|
||||||
|
Assert.Equal("13", await CodeOf("Maintenance & Repair"));
|
||||||
|
Assert.Equal("13", await CodeOf("Cleaning Supplies"));
|
||||||
|
Assert.Equal("13", await CodeOf("Craft Supplies"));
|
||||||
|
// Building repairs & maintenance are part of Occupancy (line 16), not equipment (line 13).
|
||||||
|
Assert.Equal("16", await CodeOf("Repairs & Maintenance"));
|
||||||
|
// Appreciation/outreach gifts are deliberately mapped to Other (line 24), not left unmapped.
|
||||||
|
Assert.Equal("24", await CodeOf("Gifts"));
|
||||||
|
// Visitation is a travel/program cost, not a grant to an individual.
|
||||||
|
Assert.Equal("17", await CodeOf("Visit Expenses"));
|
||||||
|
// Missions support paid to individual missionaries → line 2, not line 1 (organizations).
|
||||||
|
Assert.Equal("2", await CodeOf("Missionary Support"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedForm990Lines_RemapsExistingBadMapping_ButNotAdminOverride()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||||
|
|
||||||
|
// Simulate a database seeded by the OLD code: Visit Expenses on line 2, Missionary
|
||||||
|
// Support on line 1. Also simulate an admin who deliberately moved one elsewhere.
|
||||||
|
var lineByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
|
||||||
|
var visit = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Visit Expenses");
|
||||||
|
var missionary = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Missionary Support");
|
||||||
|
var transfer = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Offering Transfer");
|
||||||
|
visit.Form990LineId = lineByCode["2"]; // old (wrong) value → should be corrected
|
||||||
|
missionary.Form990LineId = lineByCode["1"]; // old (wrong) value → should be corrected
|
||||||
|
transfer.Form990LineId = lineByCode["24"]; // admin override → must be left alone
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||||
|
|
||||||
|
await db.Entry(visit).ReloadAsync();
|
||||||
|
await db.Entry(missionary).ReloadAsync();
|
||||||
|
await db.Entry(transfer).ReloadAsync();
|
||||||
|
Assert.Equal(lineByCode["17"], visit.Form990LineId); // corrected 2 → 17
|
||||||
|
Assert.Equal(lineByCode["2"], missionary.Form990LineId); // corrected 1 → 2
|
||||||
|
Assert.Equal(lineByCode["24"], transfer.Form990LineId); // admin edit preserved
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,8 @@ public class DisbursementServiceTests
|
|||||||
var db = BuildDb(userId);
|
var db = BuildDb(userId);
|
||||||
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
|
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.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
|
||||||
db.SaveChanges();
|
db.SaveChanges();
|
||||||
var fs = new FakeStorage();
|
var fs = new FakeStorage();
|
||||||
return (SvcAs(db, fs, userId), db, fs);
|
return (SvcAs(db, fs, userId), db, fs);
|
||||||
@@ -73,8 +75,9 @@ public class DisbursementServiceTests
|
|||||||
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
|
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
|
||||||
{
|
{
|
||||||
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
|
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
|
||||||
MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
|
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
|
||||||
MemberId = memberId, VendorName = vendor,
|
MemberId = memberId, VendorName = vendor,
|
||||||
|
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = amount } },
|
||||||
};
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -97,6 +100,28 @@ public class DisbursementServiceTests
|
|||||||
Assert.Equal("1 Main St", member.Address);
|
Assert.Equal("1 Main St", member.Address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GroupedWorklist_MultiCategoryExpense_ShowsMultipleLabel()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build();
|
||||||
|
db.Expenses.Add(new Expense
|
||||||
|
{
|
||||||
|
Type = "VendorPayment", Status = "Approved", Amount = 50m, Description = "mixed invoice",
|
||||||
|
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1), VendorName = "Costco",
|
||||||
|
Lines =
|
||||||
|
{
|
||||||
|
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
|
||||||
|
new ExpenseLine { CategoryGroupId = 2, SubCategoryId = 2, Amount = 20m },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var groups = await svc.GetApprovedUnpaidGroupedAsync();
|
||||||
|
|
||||||
|
var line = groups.Single(g => g.PayeeType == "Vendor").Lines.Single();
|
||||||
|
Assert.Equal("Multiple / 多類別", line.CategoryName);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
|
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,4 +58,23 @@ public class ExpenseCategoryServiceTests
|
|||||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||||
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
|
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAndGet_RoundTrips_Form990LineId()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 1, LineCode = "24", Name_en = "Other" });
|
||||||
|
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = new ExpenseCategoryService(db);
|
||||||
|
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Personnel", Form990LineId = 1 });
|
||||||
|
var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Salary & Wages", Form990LineId = 7 });
|
||||||
|
|
||||||
|
var all = await svc.GetAllAsync(includeInactive: true);
|
||||||
|
var sub = all.Single(g => g.Id == gid).SubCategories.Single(s => s.Id == sid);
|
||||||
|
Assert.Equal(7, sub.Form990LineId);
|
||||||
|
Assert.Equal("7", sub.Form990LineCode);
|
||||||
|
Assert.Equal(1, all.Single(g => g.Id == gid).Form990LineId);
|
||||||
|
Assert.Equal("24", all.Single(g => g.Id == gid).Form990LineCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ using ROLAC.API.Data;
|
|||||||
using ROLAC.API.Data.Interceptors;
|
using ROLAC.API.Data.Interceptors;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
using ROLAC.API.Services.Storage;
|
using ROLAC.API.Services.Storage;
|
||||||
|
using ROLAC.API.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ROLAC.API.Tests.Services;
|
namespace ROLAC.API.Tests.Services;
|
||||||
@@ -55,6 +58,14 @@ public class ExpenseServiceTests
|
|||||||
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||||
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new ExpenseService(db, http.Object, fs, audit);
|
||||||
|
}
|
||||||
|
|
||||||
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
|
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
|
||||||
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
|
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
|
||||||
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId)
|
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId)
|
||||||
@@ -67,14 +78,20 @@ public class ExpenseServiceTests
|
|||||||
|
|
||||||
private static CreateExpenseRequest Reimb() => new()
|
private static CreateExpenseRequest Reimb() => new()
|
||||||
{
|
{
|
||||||
Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
|
Type = "StaffReimbursement", MinistryId = 1,
|
||||||
Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
|
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 45.50m } },
|
||||||
|
Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
|
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
|
||||||
{
|
{
|
||||||
Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId,
|
Type = r.Type, MinistryId = r.MinistryId,
|
||||||
SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description,
|
Lines = r.Lines.Select(l => new ExpenseLineInput
|
||||||
|
{
|
||||||
|
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
|
||||||
|
Amount = l.Amount, FunctionalClass = l.FunctionalClass, Description = l.Description,
|
||||||
|
}).ToList(),
|
||||||
|
Description = r.Description,
|
||||||
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
|
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
|
||||||
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
||||||
};
|
};
|
||||||
@@ -207,7 +224,7 @@ public class ExpenseServiceTests
|
|||||||
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
||||||
|
|
||||||
var edit = CloneToUpdate(Reimb());
|
var edit = CloneToUpdate(Reimb());
|
||||||
edit.Amount = 99.99m;
|
edit.Lines[0].Amount = 99.99m;
|
||||||
await svc.UpdateAsync(id, edit, isFinance: false);
|
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||||
|
|
||||||
var e = await db.Expenses.FindAsync(id);
|
var e = await db.Expenses.FindAsync(id);
|
||||||
@@ -248,6 +265,84 @@ public class ExpenseServiceTests
|
|||||||
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
|
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PersistsFunctionalClass_AndGetReturnsIt()
|
||||||
|
{
|
||||||
|
var db = BuildDb("u1");
|
||||||
|
db.Ministries.Add(new ROLAC.API.Entities.Ministry { Id = 1, Name_en = "Admin" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ROLAC.API.Entities.ExpenseCategoryGroup { Id = 1, Name_en = "Other" });
|
||||||
|
db.ExpenseSubCategories.Add(new ROLAC.API.Entities.ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = SvcAs(db, new FakeStorage(), "u1");
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(new CreateExpenseRequest
|
||||||
|
{
|
||||||
|
Type = "VendorPayment", MinistryId = 1,
|
||||||
|
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m, FunctionalClass = "ManagementGeneral" } },
|
||||||
|
Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
|
||||||
|
}, isFinance: true);
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
Assert.Equal("ManagementGeneral", dto!.Lines.Single().FunctionalClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_MultiLine_SetsHeaderTotal_AndRoundTripsLines()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build("u1");
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var r = new CreateExpenseRequest
|
||||||
|
{
|
||||||
|
Type = "VendorPayment", MinistryId = 1, VendorName = "Costco",
|
||||||
|
Description = "Mixed invoice", ExpenseDate = new DateOnly(2026, 5, 1),
|
||||||
|
Lines =
|
||||||
|
{
|
||||||
|
new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
|
||||||
|
new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 12.50m },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
var id = await svc.CreateAsync(r, isFinance: true);
|
||||||
|
|
||||||
|
Assert.Equal(42.50m, (await db.Expenses.FindAsync(id))!.Amount);
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
Assert.Equal(2, dto!.Lines.Count);
|
||||||
|
Assert.Equal(42.50m, dto.Amount);
|
||||||
|
Assert.Equal(2, dto.LineCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithNoLines_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = Build("u1");
|
||||||
|
var r = Reimb(); r.Lines.Clear();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r, isFinance: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_ReplacesLines_AndRecomputesTotal()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build("alice");
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
|
||||||
|
var edit = CloneToUpdate(Reimb());
|
||||||
|
edit.Lines = new()
|
||||||
|
{
|
||||||
|
new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 10m },
|
||||||
|
new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 5m },
|
||||||
|
};
|
||||||
|
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||||
|
|
||||||
|
Assert.Equal(15m, (await db.Expenses.FindAsync(id))!.Amount);
|
||||||
|
Assert.Equal(2, await db.ExpenseLines.CountAsync(l => l.ExpenseId == id));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Receipt_SaveThenOpen_RoundTrips()
|
public async Task Receipt_SaveThenOpen_RoundTrips()
|
||||||
{
|
{
|
||||||
@@ -258,4 +353,93 @@ public class ExpenseServiceTests
|
|||||||
var got = await svc.OpenReceiptAsync(id, isFinance: true);
|
var got = await svc.OpenReceiptAsync(id, isFinance: true);
|
||||||
Assert.NotNull(got);
|
Assert.NotNull(got);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reject_WritesAuditEntry_WithReason()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
|
||||||
|
var audit = new CapturingAuditLogger();
|
||||||
|
await SvcAs(db, fs, "finance", audit).RejectAsync(id, "Receipt unclear, please retake");
|
||||||
|
|
||||||
|
var entry = Assert.Single(audit.Entries);
|
||||||
|
Assert.Equal(AuditActions.ExpenseRejected, entry.Action);
|
||||||
|
Assert.Equal(AuditCategories.Business, entry.Category);
|
||||||
|
Assert.Equal(nameof(ROLAC.API.Entities.Expense), entry.EntityName);
|
||||||
|
Assert.Equal(id.ToString(), entry.EntityId);
|
||||||
|
Assert.Contains("Receipt unclear", entry.Summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resubmit_FromRejected_ReturnsToPending_AndClearsReview()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt missing");
|
||||||
|
|
||||||
|
// Owner fixes the issue and re-submits.
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal("PendingApproval", e!.Status);
|
||||||
|
Assert.Null(e.ReviewedBy);
|
||||||
|
Assert.Null(e.ReviewedAt);
|
||||||
|
Assert.Null(e.ReviewNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_OwnRejected_AsNonFinance_Succeeds()
|
||||||
|
{
|
||||||
|
// A rejected reimbursement can be corrected by its owner before re-submitting.
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
await SvcAs(db, fs, "finance").RejectAsync(id, "Amount does not match receipt");
|
||||||
|
|
||||||
|
var edit = CloneToUpdate(Reimb());
|
||||||
|
edit.Lines[0].Amount = 77.77m;
|
||||||
|
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||||
|
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal(77.77m, e!.Amount);
|
||||||
|
Assert.Equal("Rejected", e.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveReceipt_OwnRejected_AsNonFinance_Succeeds()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt unclear, please retake");
|
||||||
|
|
||||||
|
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
|
||||||
|
await svc.SaveReceiptAsync(id, input, "retake.jpg", isFinance: false);
|
||||||
|
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_ResolvesReviewerName_MemberFullName_EmailFallback()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
// Reviewer linked to a member → shows the member's full name.
|
||||||
|
db.Members.Add(new Member { Id = 5, FirstName_en = "Sam", LastName_en = "Approver" });
|
||||||
|
db.Users.Add(new AppUser { Id = "reviewer-with-member", MemberId = 5 });
|
||||||
|
// Reviewer with no member → falls back to email.
|
||||||
|
db.Users.Add(new AppUser { Id = "reviewer-no-member", Email = "nomember@church.org" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var withMember = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(withMember);
|
||||||
|
await SvcAs(db, fs, "reviewer-with-member").ApproveAsync(withMember);
|
||||||
|
Assert.Equal("Sam Approver", (await svc.GetByIdAsync(withMember))!.ReviewedByName);
|
||||||
|
|
||||||
|
var noMember = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(noMember);
|
||||||
|
await SvcAs(db, fs, "reviewer-no-member").RejectAsync(noMember, "Duplicate submission");
|
||||||
|
Assert.Equal("nomember@church.org", (await svc.GetByIdAsync(noMember))!.ReviewedByName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class Form990ReportServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext BuildDb()
|
||||||
|
{
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
|
||||||
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries", SortOrder = 5 });
|
||||||
|
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 24, LineCode = "24", Name_en = "Other", SortOrder = 21 });
|
||||||
|
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "ManagementGeneral" });
|
||||||
|
db.Ministries.Add(new Ministry { Id = 2, Name_en = "Worship", DefaultFunctionalClass = "Program" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel", Form990LineId = 24 });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Salary", Form990LineId = 7 });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 1, Name_en = "Misc", Form990LineId = null });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new()
|
||||||
|
{
|
||||||
|
MinistryId = min, Type = "VendorPayment",
|
||||||
|
Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10),
|
||||||
|
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = sub, Amount = amt, FunctionalClass = fc } },
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Statement_AggregatesByLineAndFunction_WithFallbackAndUnmappedCount()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await SeedAsync(db);
|
||||||
|
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
|
||||||
|
db.Expenses.Add(Exp(1, 1, 40m, "Approved"));
|
||||||
|
db.Expenses.Add(Exp(2, 2, 25m, "Paid"));
|
||||||
|
db.Expenses.Add(Exp(2, 1, 999m, "Draft"));
|
||||||
|
db.Expenses.Add(Exp(1, 1, 10m, "Paid", fc: "Program"));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = new Form990ReportService(db);
|
||||||
|
|
||||||
|
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
|
||||||
|
|
||||||
|
var line7 = stmt.Rows.Single(r => r.LineCode == "7");
|
||||||
|
Assert.Equal(110m, line7.Program);
|
||||||
|
Assert.Equal(40m, line7.ManagementGeneral);
|
||||||
|
Assert.Equal(150m, line7.Total);
|
||||||
|
var line24 = stmt.Rows.Single(r => r.LineCode == "24");
|
||||||
|
Assert.Equal(25m, line24.Program);
|
||||||
|
Assert.Equal(1, stmt.UnmappedExpenseCount);
|
||||||
|
Assert.Equal(175m, stmt.GrandTotal);
|
||||||
|
Assert.Equal(135m, stmt.ProgramTotal);
|
||||||
|
Assert.Equal(40m, stmt.ManagementGeneralTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Statement_RespectsDateRange()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await SeedAsync(db);
|
||||||
|
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
|
||||||
|
var older = Exp(2, 1, 500m, "Paid"); older.ExpenseDate = new DateOnly(2026, 1, 1);
|
||||||
|
db.Expenses.Add(older);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = new Form990ReportService(db);
|
||||||
|
|
||||||
|
var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31));
|
||||||
|
Assert.Equal(100m, stmt.GrandTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Statement_SplitsOneExpenseAcrossLines()
|
||||||
|
{
|
||||||
|
// One invoice with two lines of different categories must land on two different 990 lines.
|
||||||
|
using var db = BuildDb();
|
||||||
|
await SeedAsync(db);
|
||||||
|
db.Expenses.Add(new Expense
|
||||||
|
{
|
||||||
|
MinistryId = 2, Type = "VendorPayment", Status = "Paid", Amount = 70m,
|
||||||
|
Description = "mixed", ExpenseDate = new DateOnly(2026, 5, 10),
|
||||||
|
Lines =
|
||||||
|
{
|
||||||
|
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m }, // sub→line 7
|
||||||
|
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 2, Amount = 20m }, // sub unmapped→group fallback line 24
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = new Form990ReportService(db);
|
||||||
|
|
||||||
|
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
|
||||||
|
|
||||||
|
Assert.Equal(50m, stmt.Rows.Single(r => r.LineCode == "7").Program); // ministry 2 default = Program
|
||||||
|
Assert.Equal(20m, stmt.Rows.Single(r => r.LineCode == "24").Program);
|
||||||
|
Assert.Equal(70m, stmt.GrandTotal);
|
||||||
|
Assert.Equal(1, stmt.UnmappedExpenseCount); // one unmapped line
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class MealAttendanceServiceTests
|
||||||
|
{
|
||||||
|
// MealAttendance is auditable, so the InMemory provider requires CreatedBy/UpdatedBy
|
||||||
|
// to be set before insert. Wire in the AuditSaveChangesInterceptor (as the other
|
||||||
|
// service tests do) so those columns are stamped automatically on SaveChanges.
|
||||||
|
private static AppDbContext BuildDb()
|
||||||
|
{
|
||||||
|
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||||
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(
|
||||||
|
new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MealAttendanceService(db);
|
||||||
|
var date = new DateOnly(2026, 5, 31);
|
||||||
|
|
||||||
|
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
|
||||||
|
|
||||||
|
Assert.Equal("2026-05-31", result.Date);
|
||||||
|
Assert.Equal(40, result.Adult);
|
||||||
|
Assert.Equal(12, result.Youth);
|
||||||
|
Assert.Equal(8, result.Kid);
|
||||||
|
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MealAttendanceService(db);
|
||||||
|
var date = new DateOnly(2026, 5, 31);
|
||||||
|
await svc.SetCountsAsync(date, 40, 12, 8);
|
||||||
|
|
||||||
|
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
|
||||||
|
|
||||||
|
Assert.Equal(50, result.Adult);
|
||||||
|
Assert.Equal(0, result.Youth); // negative clamped to zero
|
||||||
|
Assert.Equal(0, result.Kid);
|
||||||
|
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
|
|||||||
using Moq;
|
using Moq;
|
||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
using ROLAC.API.Data.Interceptors;
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.DTOs.Ministry;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -41,4 +42,19 @@ public class MinistryServiceTests
|
|||||||
Assert.Equal("A", active[0].Name_en);
|
Assert.Equal("A", active[0].Name_en);
|
||||||
Assert.Equal(3, all.Count);
|
Assert.Equal(3, all.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_DefaultsFunctionalClassToProgram_AndUpdateChangesIt()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MinistryService(db);
|
||||||
|
var id = await svc.CreateAsync(new CreateMinistryRequest { Name_en = "Worship" });
|
||||||
|
|
||||||
|
var afterCreate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
|
||||||
|
Assert.Equal("Program", afterCreate.DefaultFunctionalClass);
|
||||||
|
|
||||||
|
await svc.UpdateAsync(id, new UpdateMinistryRequest { Name_en = "Worship", DefaultFunctionalClass = "ManagementGeneral" });
|
||||||
|
var afterUpdate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
|
||||||
|
Assert.Equal("ManagementGeneral", afterUpdate.DefaultFunctionalClass);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ public class MonthlyStatementServiceTests
|
|||||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
|
||||||
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 1000m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 5, 10) });
|
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 1000m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 5, 10) });
|
||||||
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 500m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 6, 1) });
|
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 500m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 6, 1) });
|
||||||
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20) });
|
db.Expenses.Add(new Expense { MinistryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 300m } } });
|
||||||
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21) });
|
db.Expenses.Add(new Expense { MinistryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 999m } } });
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
var svc = Build(db);
|
var svc = Build(db);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ROLAC.API.Services.Notifications;
|
using ROLAC.API.Services.Notifications;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -8,6 +7,14 @@ namespace ROLAC.API.Tests.Services.Notifications;
|
|||||||
|
|
||||||
public class LineMessageChannelTests
|
public class LineMessageChannelTests
|
||||||
{
|
{
|
||||||
|
// Stub settings provider returning fixed SMTP/Line values for the channel under test.
|
||||||
|
private sealed class StubSettings : INotificationSettingsService
|
||||||
|
{
|
||||||
|
public SmtpOptions GetSmtp() => new();
|
||||||
|
public LineOptions GetLine() => new() { ChannelAccessToken = "tok", ChannelSecret = "sec" };
|
||||||
|
public void Reload() { }
|
||||||
|
}
|
||||||
|
|
||||||
// Captures the outgoing request and returns a canned response.
|
// Captures the outgoing request and returns a canned response.
|
||||||
private sealed class CapturingHandler : HttpMessageHandler
|
private sealed class CapturingHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
@@ -28,8 +35,7 @@ public class LineMessageChannelTests
|
|||||||
private static LineMessageChannel BuildChannel(CapturingHandler handler)
|
private static LineMessageChannel BuildChannel(CapturingHandler handler)
|
||||||
{
|
{
|
||||||
var http = new HttpClient(handler);
|
var http = new HttpClient(handler);
|
||||||
var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" });
|
return new LineMessageChannel(http, new StubSettings());
|
||||||
return new LineMessageChannel(http, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -164,4 +164,27 @@ public class OfferingSessionServiceTests
|
|||||||
Assert.Equal("PP-456", line.PayPalTransactionId);
|
Assert.Equal("PP-456", line.PayPalTransactionId);
|
||||||
Assert.Equal("C-789", line.CheckNumber);
|
Assert.Equal("C-789", line.CheckNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||||
|
|
||||||
|
var withDate = new DateOnly(2026, 5, 31);
|
||||||
|
var withoutDate = new DateOnly(2026, 5, 24);
|
||||||
|
await svc.CreateAsync(BuildRequest(catId, withDate));
|
||||||
|
await svc.CreateAsync(BuildRequest(catId, withoutDate));
|
||||||
|
db.MealAttendances.Add(new MealAttendance
|
||||||
|
{ AttendanceDate = withDate, AdultCount = 40, YouthCount = 12, KidCount = 8 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var page = await svc.GetPagedAsync(1, 20, null, null);
|
||||||
|
|
||||||
|
var withItem = page.Items.Single(i => i.SessionDate == "2026-05-31");
|
||||||
|
var withoutItem = page.Items.Single(i => i.SessionDate == "2026-05-24");
|
||||||
|
Assert.Equal(60, withItem.SundayAttendanceCount); // 40 + 12 + 8
|
||||||
|
Assert.Null(withoutItem.SundayAttendanceCount); // no attendance row -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>Records every audit Write so tests can assert on the emitted actions/summaries.</summary>
|
||||||
|
public sealed class CapturingAuditLogger : IAuditLogger
|
||||||
|
{
|
||||||
|
public readonly record struct Entry(string Action, string Category, string? EntityName, string? EntityId, string? Summary);
|
||||||
|
|
||||||
|
public readonly List<Entry> Entries = new();
|
||||||
|
|
||||||
|
public void Write(
|
||||||
|
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
|
||||||
|
string? entityName = null, string? entityId = null, string? summary = null,
|
||||||
|
object? before = null, object? after = null,
|
||||||
|
string? userId = null, string? userEmail = null, string? ipAddress = null)
|
||||||
|
{
|
||||||
|
Entries.Add(new Entry(action, category, entityName, entityId, summary));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ public static class Modules
|
|||||||
public const string OfferingSessions = "OfferingSessions";
|
public const string OfferingSessions = "OfferingSessions";
|
||||||
public const string Ministries = "Ministries";
|
public const string Ministries = "Ministries";
|
||||||
public const string FinanceDashboard = "FinanceDashboard";
|
public const string FinanceDashboard = "FinanceDashboard";
|
||||||
|
public const string Form990Report = "Form990Report";
|
||||||
public const string MonthlyStatements = "MonthlyStatements";
|
public const string MonthlyStatements = "MonthlyStatements";
|
||||||
public const string ChurchProfile = "ChurchProfile";
|
public const string ChurchProfile = "ChurchProfile";
|
||||||
public const string Disbursements = "Disbursements";
|
public const string Disbursements = "Disbursements";
|
||||||
@@ -23,6 +24,7 @@ public static class Modules
|
|||||||
public const string Permissions = "Permissions";
|
public const string Permissions = "Permissions";
|
||||||
public const string SystemLogs = "SystemLogs";
|
public const string SystemLogs = "SystemLogs";
|
||||||
public const string AuditLogs = "AuditLogs";
|
public const string AuditLogs = "AuditLogs";
|
||||||
|
public const string Settings = "Settings";
|
||||||
|
|
||||||
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
|
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
|
||||||
public static readonly IReadOnlyList<string> All =
|
public static readonly IReadOnlyList<string> All =
|
||||||
@@ -36,6 +38,7 @@ public static class Modules
|
|||||||
OfferingSessions,
|
OfferingSessions,
|
||||||
Ministries,
|
Ministries,
|
||||||
FinanceDashboard,
|
FinanceDashboard,
|
||||||
|
Form990Report,
|
||||||
MonthlyStatements,
|
MonthlyStatements,
|
||||||
ChurchProfile,
|
ChurchProfile,
|
||||||
Disbursements,
|
Disbursements,
|
||||||
@@ -43,6 +46,7 @@ public static class Modules
|
|||||||
Permissions,
|
Permissions,
|
||||||
SystemLogs,
|
SystemLogs,
|
||||||
AuditLogs,
|
AuditLogs,
|
||||||
|
Settings,
|
||||||
];
|
];
|
||||||
|
|
||||||
public static bool IsValid(string module) => All.Contains(module);
|
public static bool IsValid(string module) => All.Contains(module);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
|
using ROLAC.API.DTOs.Invitations;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -16,13 +17,16 @@ public class AuthController : ControllerBase
|
|||||||
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||||
|
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
|
private readonly IInvitationService _invitations;
|
||||||
private readonly UserManager<AppUser> _userManager;
|
private readonly UserManager<AppUser> _userManager;
|
||||||
private readonly IWebHostEnvironment _env;
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
public AuthController(
|
public AuthController(
|
||||||
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
|
IAuthService authService, IInvitationService invitations,
|
||||||
|
UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
|
_invitations = invitations;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_env = env;
|
_env = env;
|
||||||
}
|
}
|
||||||
@@ -186,6 +190,45 @@ public class AuthController : ControllerBase
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/auth/invitation/validate?token=...
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether an invitation token can still be used. Anonymous so the public
|
||||||
|
/// "set your password" page can decide what to show before the member types anything.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("invitation/validate")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(ValidateInvitationResult), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
|
||||||
|
=> Ok(await _invitations.ValidateAsync(token));
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/auth/accept-invitation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Consumes an invitation: sets the account password and, on success, logs the member in
|
||||||
|
/// (issues the access token + refresh cookie) so first login lands straight on the portal.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("accept-invitation")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> AcceptInvitation([FromBody] AcceptInvitationRequest request)
|
||||||
|
{
|
||||||
|
var (user, error) = await _invitations.AcceptAsync(request.Token, request.NewPassword);
|
||||||
|
if (user is null)
|
||||||
|
return BadRequest(new { message = error });
|
||||||
|
|
||||||
|
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var device = Request.Headers.UserAgent.FirstOrDefault();
|
||||||
|
var (response, raw) = await _authService.IssueSessionAsync(user, ip, device);
|
||||||
|
SetRefreshCookie(raw);
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Private helpers
|
// Private helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/form990-report")]
|
||||||
|
[HasPermission(Modules.Form990Report, PermissionActions.Read)]
|
||||||
|
public class Form990ReportController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IForm990ReportService _svc;
|
||||||
|
public Form990ReportController(IForm990ReportService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet("lines")]
|
||||||
|
public async Task<IActionResult> Lines() => Ok(await _svc.GetLinesAsync());
|
||||||
|
|
||||||
|
[HttpGet("functional-expenses")]
|
||||||
|
public async Task<IActionResult> FunctionalExpenses([FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
|
||||||
|
=> Ok(await _svc.GetFunctionalExpenseStatementAsync(from, to));
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Invitations;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin endpoints for generating and e-mailing first-login invitation links.
|
||||||
|
/// The public consume/validate endpoints live on <see cref="AuthController"/> so they can set the
|
||||||
|
/// refresh-token cookie and stay anonymous.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/invitations")]
|
||||||
|
[Authorize]
|
||||||
|
public class InvitationsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IInvitationService _invitations;
|
||||||
|
public InvitationsController(IInvitationService invitations) => _invitations = invitations;
|
||||||
|
|
||||||
|
/// <summary>POST /api/invitations — generate a link for a member; returns { token, expiresAt }.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateInvitationRequest request)
|
||||||
|
{
|
||||||
|
try { return Ok(await _invitations.CreateAsync(request)); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /api/invitations/send — e-mail an already-generated link to the member.</summary>
|
||||||
|
[HttpPost("send")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Send([FromBody] SendInvitationRequest request)
|
||||||
|
{
|
||||||
|
try { await _invitations.SendEmailAsync(request.MemberId, request.Link); return NoContent(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ROLAC.API.DTOs.Notifications;
|
using ROLAC.API.DTOs.Notifications;
|
||||||
using ROLAC.API.Services.Notifications;
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
@@ -22,14 +21,14 @@ public sealed class LineWebhookController : ControllerBase
|
|||||||
|
|
||||||
private readonly ILineNotificationService _line;
|
private readonly ILineNotificationService _line;
|
||||||
private readonly IMessageChannel _channel;
|
private readonly IMessageChannel _channel;
|
||||||
private readonly LineOptions _options;
|
private readonly INotificationSettingsService _settings;
|
||||||
|
|
||||||
public LineWebhookController(
|
public LineWebhookController(
|
||||||
ILineNotificationService line, IMessageChannel channel, IOptions<LineOptions> options)
|
ILineNotificationService line, IMessageChannel channel, INotificationSettingsService settings)
|
||||||
{
|
{
|
||||||
_line = line;
|
_line = line;
|
||||||
_channel = channel;
|
_channel = channel;
|
||||||
_options = options.Value;
|
_settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("webhook")]
|
[HttpPost("webhook")]
|
||||||
@@ -40,7 +39,7 @@ public sealed class LineWebhookController : ControllerBase
|
|||||||
var rawBody = await reader.ReadToEndAsync(ct);
|
var rawBody = await reader.ReadToEndAsync(ct);
|
||||||
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
|
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
|
||||||
|
|
||||||
if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
|
if (!LineSignature.IsValid(_settings.GetLine().ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
|
||||||
return BadRequest();
|
return BadRequest();
|
||||||
|
|
||||||
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
|
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.MealAttendance;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
@@ -23,4 +24,10 @@ public class MealAttendanceController : ControllerBase
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
|
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
|
||||||
=> Ok(await _svc.GetRangeAsync(from, to));
|
=> Ok(await _svc.GetRangeAsync(from, to));
|
||||||
|
|
||||||
|
/// <summary>Overwrite a specific Sunday's counts (back-office editor). Authenticated only.</summary>
|
||||||
|
[HttpPut("{date}")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> SetCounts(DateOnly date, [FromBody] SetAttendanceRequest body)
|
||||||
|
=> Ok(await _svc.SetCountsAsync(date, body.Adult, body.Youth, body.Kid));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Ministry;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
@@ -13,6 +15,31 @@ public class MinistriesController : ControllerBase
|
|||||||
public MinistriesController(IMinistryService svc) => _svc = svc;
|
public MinistriesController(IMinistryService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.Ministries, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Ministries, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateMinistryRequest request)
|
||||||
|
{
|
||||||
|
var id = await _svc.CreateAsync(request);
|
||||||
|
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.Ministries, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateMinistryRequest request)
|
||||||
|
{
|
||||||
|
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[HasPermission(Modules.Ministries, PermissionActions.Delete)]
|
||||||
|
public async Task<IActionResult> Deactivate(int id)
|
||||||
|
{
|
||||||
|
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public class OfferingEntryController : ControllerBase
|
|||||||
NickName = request.NickName,
|
NickName = request.NickName,
|
||||||
FirstName_zh = request.FirstName_zh,
|
FirstName_zh = request.FirstName_zh,
|
||||||
LastName_zh = request.LastName_zh,
|
LastName_zh = request.LastName_zh,
|
||||||
|
Entity = request.Entity,
|
||||||
PhoneCell = request.PhoneCell,
|
PhoneCell = request.PhoneCell,
|
||||||
Status = "Visitor",
|
Status = "Visitor",
|
||||||
Country = "USA",
|
Country = "USA",
|
||||||
@@ -73,6 +74,7 @@ public class OfferingEntryController : ControllerBase
|
|||||||
{
|
{
|
||||||
Id = id, NickName = request.NickName,
|
Id = id, NickName = request.NickName,
|
||||||
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
|
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
|
||||||
|
Entity = request.Entity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Settings;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site-wide and notification (SMTP/Line) settings, surfaced by the Church Profile → Site /
|
||||||
|
/// Notification tabs. Gated by the <c>Settings</c> permission module (super_admin bypasses).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/settings")]
|
||||||
|
[Authorize]
|
||||||
|
public class SettingsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settings;
|
||||||
|
private readonly IEmailService _email;
|
||||||
|
private readonly ILineNotificationService _line;
|
||||||
|
private readonly CurrentUserAccessor _currentUser;
|
||||||
|
|
||||||
|
public SettingsController(
|
||||||
|
ISettingsService settings,
|
||||||
|
IEmailService email,
|
||||||
|
ILineNotificationService line,
|
||||||
|
CurrentUserAccessor currentUser)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_email = email;
|
||||||
|
_line = line;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Site settings ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("site")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Read)]
|
||||||
|
public async Task<IActionResult> GetSite() => Ok(await _settings.GetSiteAsync());
|
||||||
|
|
||||||
|
[HttpPut("site")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> UpdateSite([FromBody] UpdateSiteSettingRequest request)
|
||||||
|
{
|
||||||
|
await _settings.UpdateSiteAsync(request);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification settings ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("notification")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Read)]
|
||||||
|
public async Task<IActionResult> GetNotification()
|
||||||
|
{
|
||||||
|
var dto = await _settings.GetNotificationAsync();
|
||||||
|
dto.WebhookUrl = $"{Request.Scheme}://{Request.Host}/api/line/webhook";
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("notification")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> UpdateNotification([FromBody] UpdateNotificationSettingRequest request)
|
||||||
|
{
|
||||||
|
await _settings.UpdateNotificationAsync(request);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("notification/test-email")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> TestEmail([FromBody] TestEmailRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var to = string.IsNullOrWhiteSpace(request.ToAddress) ? _currentUser.Email : request.ToAddress;
|
||||||
|
if (string.IsNullOrWhiteSpace(to))
|
||||||
|
return BadRequest(new { message = "No recipient — provide an address or set an email on your account." });
|
||||||
|
|
||||||
|
var result = await _email.SendAsync(new EmailMessage(
|
||||||
|
MemberIds: Array.Empty<int>(),
|
||||||
|
Addresses: new[] { to },
|
||||||
|
Subject: "ROLAC test email / 測試郵件",
|
||||||
|
HtmlBody: "<p>This is a test email from ROLAC notification settings.</p>"
|
||||||
|
+ "<p>這是來自 ROLAC 通知設定的測試郵件。</p>",
|
||||||
|
SentByUserId: _currentUser.UserIdOrSystem), ct);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("notification/test-line")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> TestLine([FromBody] TestLineRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (request.MemberId is null && request.GroupId is null)
|
||||||
|
return BadRequest(new { message = "Choose a bound member or group to receive the test." });
|
||||||
|
|
||||||
|
var result = await _line.SendLineAsync(
|
||||||
|
body: "ROLAC 測試訊息 / This is a test Line message from ROLAC.",
|
||||||
|
memberIds: request.MemberId is { } m ? new[] { m } : Array.Empty<int>(),
|
||||||
|
groupIds: request.GroupId is { } g ? new[] { g } : Array.Empty<int>(),
|
||||||
|
sentByUserId: _currentUser.UserIdOrSystem,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,4 +25,22 @@ public class UserInfo
|
|||||||
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
|
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
|
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The church member linked to this login account, or null for admin-only
|
||||||
|
/// accounts (no MemberId) and accounts whose member record was deleted.
|
||||||
|
/// Lets the SPA greet the user by their real name.
|
||||||
|
/// </summary>
|
||||||
|
public MemberInfo? MemberInfo { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Minimal member identity for greeting the signed-in user.</summary>
|
||||||
|
public class MemberInfo
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? NickName { get; set; }
|
||||||
|
public string FirstName_en { get; set; } = "";
|
||||||
|
public string LastName_en { get; set; } = "";
|
||||||
|
public string? FirstName_zh { get; set; }
|
||||||
|
public string? LastName_zh { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ public class ChurchProfileDto
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
|
public string? NameZh { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Website { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
@@ -18,6 +22,10 @@ public class ChurchProfileDto
|
|||||||
public class UpdateChurchProfileRequest
|
public class UpdateChurchProfileRequest
|
||||||
{
|
{
|
||||||
[Required, MaxLength(200)] public string Name { get; set; } = "";
|
[Required, MaxLength(200)] public string Name { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? NameZh { get; set; }
|
||||||
|
[MaxLength(50)] public string? Phone { get; set; }
|
||||||
|
[MaxLength(200), EmailAddress] public string? Email { get; set; }
|
||||||
|
[MaxLength(300)] public string? Website { get; set; }
|
||||||
[MaxLength(500)] public string? Address { get; set; }
|
[MaxLength(500)] public string? Address { get; set; }
|
||||||
[MaxLength(100)] public string? City { get; set; }
|
[MaxLength(100)] public string? City { get; set; }
|
||||||
[MaxLength(50)] public string? State { get; set; }
|
[MaxLength(50)] public string? State { get; set; }
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public class ExpenseSubCategoryDto
|
|||||||
public string? Name_zh { get; set; }
|
public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public string? Form990LineCode { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExpenseCategoryGroupDto
|
public class ExpenseCategoryGroupDto
|
||||||
@@ -18,6 +20,8 @@ public class ExpenseCategoryGroupDto
|
|||||||
public string? Name_zh { get; set; }
|
public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public string? Form990LineCode { get; set; }
|
||||||
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +30,7 @@ public class CreateExpenseGroupRequest
|
|||||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
||||||
{
|
{
|
||||||
@@ -38,6 +43,7 @@ public class CreateExpenseSubCategoryRequest
|
|||||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,44 +1,64 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
namespace ROLAC.API.DTOs.Expense;
|
namespace ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
public class ExpenseLineItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int CategoryGroupId { get; set; }
|
||||||
|
public string CategoryGroupName { get; set; } = "";
|
||||||
|
public int SubCategoryId { get; set; }
|
||||||
|
public string SubCategoryName { get; set; } = "";
|
||||||
|
public string? FunctionalClass { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class ExpenseListItemDto
|
public class ExpenseListItemDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Type { get; set; } = "";
|
public string Type { get; set; } = "";
|
||||||
public string Status { get; set; } = "";
|
public string Status { get; set; } = "";
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; } // header total = sum of line amounts
|
||||||
public string Description { get; set; } = "";
|
public string Description { get; set; } = "";
|
||||||
public int MinistryId { get; set; }
|
public int MinistryId { get; set; }
|
||||||
public string MinistryName { get; set; } = "";
|
public string MinistryName { get; set; } = "";
|
||||||
public int CategoryGroupId { get; set; }
|
public int LineCount { get; set; }
|
||||||
public string CategoryGroupName { get; set; } = "";
|
public string PrimaryCategoryName { get; set; } = ""; // first line's category (list hint; full breakdown via detail)
|
||||||
public int SubCategoryId { get; set; }
|
|
||||||
public string SubCategoryName { get; set; } = "";
|
|
||||||
public string? VendorName { get; set; }
|
public string? VendorName { get; set; }
|
||||||
public int? MemberId { get; set; }
|
public int? MemberId { get; set; }
|
||||||
public string? MemberName { get; set; }
|
public string? MemberName { get; set; }
|
||||||
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
||||||
public bool HasReceipt { get; set; }
|
public bool HasReceipt { get; set; }
|
||||||
public string? CheckNumber { get; set; }
|
public string? CheckNumber { get; set; }
|
||||||
|
// Review outcome — surfaced on the list so the Status column can show "Approved/Rejected by X · date".
|
||||||
|
public string? ReviewedByName { get; set; } // resolved Member full name, email fallback
|
||||||
|
public DateTimeOffset? ReviewedAt { get; set; }
|
||||||
|
public string? ReviewNotes { get; set; } // reject reason (or approval note)
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExpenseDto : ExpenseListItemDto
|
public class ExpenseDto : ExpenseListItemDto
|
||||||
{
|
{
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? ReviewNotes { get; set; }
|
|
||||||
public string? SubmittedBy { get; set; }
|
public string? SubmittedBy { get; set; }
|
||||||
public DateTimeOffset? SubmittedAt { get; set; }
|
public DateTimeOffset? SubmittedAt { get; set; }
|
||||||
public DateTimeOffset? ReviewedAt { get; set; }
|
|
||||||
public DateTimeOffset? PaidAt { get; set; }
|
public DateTimeOffset? PaidAt { get; set; }
|
||||||
|
public List<ExpenseLineItemDto> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExpenseLineInput
|
||||||
|
{
|
||||||
|
[Required] public int CategoryGroupId { get; set; }
|
||||||
|
[Required] public int SubCategoryId { get; set; }
|
||||||
|
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
|
||||||
|
[MaxLength(20)] public string? FunctionalClass { get; set; }
|
||||||
|
[MaxLength(500)] public string? Description { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CreateExpenseRequest
|
public class CreateExpenseRequest
|
||||||
{
|
{
|
||||||
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
|
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
|
||||||
[Required] public int MinistryId { get; set; }
|
[Required] public int MinistryId { get; set; }
|
||||||
[Required] public int CategoryGroupId { get; set; }
|
[Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
|
||||||
[Required] public int SubCategoryId { get; set; }
|
|
||||||
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
|
|
||||||
[Required, MaxLength(500)] public string Description { get; set; } = "";
|
[Required, MaxLength(500)] public string Description { get; set; } = "";
|
||||||
[MaxLength(200)] public string? VendorName { get; set; }
|
[MaxLength(200)] public string? VendorName { get; set; }
|
||||||
public int? MemberId { get; set; } // ignored for self-service (server uses caller)
|
public int? MemberId { get; set; } // ignored for self-service (server uses caller)
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Finance;
|
||||||
|
|
||||||
|
/// <summary>One Part IX row: a 990 line split across the three functional columns.</summary>
|
||||||
|
public class FunctionalExpenseRowDto
|
||||||
|
{
|
||||||
|
public string LineCode { get; set; } = "";
|
||||||
|
public string Name_en { get; set; } = "";
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public decimal Program { get; set; }
|
||||||
|
public decimal ManagementGeneral { get; set; }
|
||||||
|
public decimal Fundraising { get; set; }
|
||||||
|
public decimal Total { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The full Part IX Statement of Functional Expenses for a date range.</summary>
|
||||||
|
public class FunctionalExpenseStatementDto
|
||||||
|
{
|
||||||
|
public List<FunctionalExpenseRowDto> Rows { get; set; } = [];
|
||||||
|
public decimal ProgramTotal { get; set; }
|
||||||
|
public decimal ManagementGeneralTotal { get; set; }
|
||||||
|
public decimal FundraisingTotal { get; set; }
|
||||||
|
public decimal GrandTotal { get; set; }
|
||||||
|
/// <summary>Expenses with no explicit 990 mapping (counted under line 24). Prompts mapping cleanup.</summary>
|
||||||
|
public int UnmappedExpenseCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A single IRS Form 990 expense line from the catalog (used to populate mapping dropdowns).</summary>
|
||||||
|
public class Form990ExpenseLineDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string LineCode { get; set; } = "";
|
||||||
|
public string Name_en { get; set; } = "";
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -9,4 +9,5 @@ public class MemberTypeaheadDto
|
|||||||
public string? NickName { get; set; }
|
public string? NickName { get; set; }
|
||||||
public string FirstName_en { get; set; } = "";
|
public string FirstName_en { get; set; } = "";
|
||||||
public string LastName_en { get; set; } = "";
|
public string LastName_en { get; set; } = "";
|
||||||
|
public string? Entity { get; set; } // company / business name (公司行號), if any
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ public class OfferingSessionListItemDto
|
|||||||
public decimal Difference { get; set; }
|
public decimal Difference { get; set; }
|
||||||
public int LineCount { get; set; }
|
public int LineCount { get; set; }
|
||||||
public bool HasProof { get; set; }
|
public bool HasProof { get; set; }
|
||||||
|
public int? SundayAttendanceCount { get; set; } // null = no attendance recorded for the date
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ public class QuickAddMemberRequest
|
|||||||
[MaxLength(100)] public string? NickName { get; set; }
|
[MaxLength(100)] public string? NickName { get; set; }
|
||||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||||
|
[MaxLength(200)] public string? Entity { get; set; }
|
||||||
[MaxLength(30)] public string? PhoneCell { get; set; }
|
[MaxLength(30)] public string? PhoneCell { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ROLAC.API.DTOs.Invitations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin request to generate a first-login invitation link for a member. If the member has no
|
||||||
|
/// account yet, one is auto-created (no password) using <see cref="Email"/> or the member's email.
|
||||||
|
/// </summary>
|
||||||
|
public class CreateInvitationRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public int MemberId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional override for the login email when the member has none on file.</summary>
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Roles to assign when an account is created. Defaults to ["member"].</summary>
|
||||||
|
public List<string>? Roles { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of generating an invitation — the raw token is returned ONCE.</summary>
|
||||||
|
public class CreateInvitationResult
|
||||||
|
{
|
||||||
|
public string Token { get; set; } = null!;
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Admin request to e-mail an already-generated invitation link to the member.</summary>
|
||||||
|
public class SendInvitationRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public int MemberId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Link { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public result describing whether an invitation token can still be used.</summary>
|
||||||
|
public class ValidateInvitationResult
|
||||||
|
{
|
||||||
|
public bool Valid { get; set; }
|
||||||
|
public bool Expired { get; set; }
|
||||||
|
public string? MemberName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Public request to consume an invitation and set the account password.</summary>
|
||||||
|
public class AcceptInvitationRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Token { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(128, MinimumLength = 8)]
|
||||||
|
public string NewPassword { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ROLAC.API.DTOs.MealAttendance;
|
||||||
|
|
||||||
|
/// <summary>Absolute head-counts to write for one Sunday, from the back-office editor.</summary>
|
||||||
|
public class SetAttendanceRequest
|
||||||
|
{
|
||||||
|
public int Adult { get; set; }
|
||||||
|
public int Youth { get; set; }
|
||||||
|
public int Kid { get; set; }
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ public class CreateMemberRequest
|
|||||||
[MaxLength(100)] public string? NickName { get; set; }
|
[MaxLength(100)] public string? NickName { get; set; }
|
||||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||||
|
[MaxLength(200)] public string? Entity { get; set; }
|
||||||
[MaxLength(10)] public string? Gender { get; set; }
|
[MaxLength(10)] public string? Gender { get; set; }
|
||||||
public DateOnly? DateOfBirth { get; set; }
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
public DateOnly? BaptismDate { get; set; }
|
public DateOnly? BaptismDate { get; set; }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public class MemberListItemDto
|
|||||||
public string? NickName { get; set; }
|
public string? NickName { get; set; }
|
||||||
public string? FirstName_zh { get; set; }
|
public string? FirstName_zh { get; set; }
|
||||||
public string? LastName_zh { get; set; }
|
public string? LastName_zh { get; set; }
|
||||||
|
public string? Entity { get; set; }
|
||||||
public string Status { get; set; } = "";
|
public string Status { get; set; } = "";
|
||||||
public string? Email { get; set; }
|
public string? Email { get; set; }
|
||||||
public string? PhoneCell { get; set; }
|
public string? PhoneCell { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Ministry;
|
||||||
|
|
||||||
|
public class CreateMinistryRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
|
[MaxLength(500)] public string? Description_en { get; set; }
|
||||||
|
[MaxLength(500)] public string? Description_zh { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ public class MinistryDto
|
|||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name_en { get; set; } = "";
|
public string Name_en { get; set; } = "";
|
||||||
public string? Name_zh { get; set; }
|
public string? Name_zh { get; set; }
|
||||||
|
public string? Description_en { get; set; }
|
||||||
|
public string? Description_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
public string DefaultFunctionalClass { get; set; } = "Program";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Ministry;
|
||||||
|
|
||||||
|
public class UpdateMinistryRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
|
[MaxLength(500)] public string? Description_en { get; set; }
|
||||||
|
[MaxLength(500)] public string? Description_zh { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Settings;
|
||||||
|
|
||||||
|
// ── Site settings ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class SiteSettingDto
|
||||||
|
{
|
||||||
|
public string SiteTitle { get; set; } = "";
|
||||||
|
public string? SiteTitleZh { get; set; }
|
||||||
|
public string DefaultLanguage { get; set; } = "en";
|
||||||
|
public string TimeZone { get; set; } = "";
|
||||||
|
public string DateFormat { get; set; } = "";
|
||||||
|
public string Currency { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateSiteSettingRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(200)] public string SiteTitle { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? SiteTitleZh { get; set; }
|
||||||
|
[Required, MaxLength(10)] public string DefaultLanguage { get; set; } = "en";
|
||||||
|
[Required, MaxLength(100)] public string TimeZone { get; set; } = "";
|
||||||
|
[Required, MaxLength(50)] public string DateFormat { get; set; } = "";
|
||||||
|
[Required, MaxLength(10)] public string Currency { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification settings ──────────────────────────────────────────────────
|
||||||
|
// Secrets are never returned. The DTO exposes only whether each secret is configured; the UI
|
||||||
|
// shows a write-only field where a blank value on update means "keep the stored secret".
|
||||||
|
|
||||||
|
public class NotificationSettingDto
|
||||||
|
{
|
||||||
|
public bool EnableEmail { get; set; }
|
||||||
|
public string SmtpHost { get; set; } = "";
|
||||||
|
public int SmtpPort { get; set; }
|
||||||
|
public bool SmtpUseSsl { get; set; }
|
||||||
|
public string SmtpUser { get; set; } = "";
|
||||||
|
public string FromAddress { get; set; } = "";
|
||||||
|
public string FromName { get; set; } = "";
|
||||||
|
public bool HasSmtpPassword { get; set; }
|
||||||
|
|
||||||
|
public bool EnableLine { get; set; }
|
||||||
|
public bool HasLineChannelAccessToken { get; set; }
|
||||||
|
public bool HasLineChannelSecret { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Read-only webhook URL to register in the Line console (derived from the request).</summary>
|
||||||
|
public string WebhookUrl { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateNotificationSettingRequest
|
||||||
|
{
|
||||||
|
public bool EnableEmail { get; set; }
|
||||||
|
[MaxLength(200)] public string SmtpHost { get; set; } = "";
|
||||||
|
[Range(0, 65535)] public int SmtpPort { get; set; } = 587;
|
||||||
|
public bool SmtpUseSsl { get; set; } = true;
|
||||||
|
[MaxLength(200)] public string SmtpUser { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? FromAddress { get; set; }
|
||||||
|
[MaxLength(200)] public string? FromName { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored password unchanged.</summary>
|
||||||
|
[MaxLength(500)] public string? SmtpPassword { get; set; }
|
||||||
|
|
||||||
|
public bool EnableLine { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored token unchanged.</summary>
|
||||||
|
[MaxLength(500)] public string? LineChannelAccessToken { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored secret unchanged.</summary>
|
||||||
|
[MaxLength(200)] public string? LineChannelSecret { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test-send requests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class TestEmailRequest
|
||||||
|
{
|
||||||
|
/// <summary>Optional override; defaults to the current user's email when omitted.</summary>
|
||||||
|
[MaxLength(200), EmailAddress] public string? ToAddress { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TestLineRequest
|
||||||
|
{
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public int? GroupId { get; set; }
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
public DbSet<UserInvitation> UserInvitations => Set<UserInvitation>();
|
||||||
public DbSet<Member> Members => Set<Member>();
|
public DbSet<Member> Members => Set<Member>();
|
||||||
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
||||||
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
||||||
@@ -19,7 +20,9 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public DbSet<Ministry> Ministries => Set<Ministry>();
|
public DbSet<Ministry> Ministries => Set<Ministry>();
|
||||||
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
||||||
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
||||||
|
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
|
||||||
public DbSet<Expense> Expenses => Set<Expense>();
|
public DbSet<Expense> Expenses => Set<Expense>();
|
||||||
|
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
|
||||||
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
||||||
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
||||||
public DbSet<Check> Checks => Set<Check>();
|
public DbSet<Check> Checks => Set<Check>();
|
||||||
@@ -32,6 +35,9 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
|
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
|
||||||
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
|
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
|
||||||
|
|
||||||
|
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
|
||||||
|
public DbSet<NotificationSetting> NotificationSettings => Set<NotificationSetting>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
@@ -53,6 +59,23 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Ignore(e => e.IsActive);
|
entity.Ignore(e => e.IsActive);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── UserInvitation (single-use, expiring first-login links) ─────────
|
||||||
|
builder.Entity<UserInvitation>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.TokenHash).IsUnique();
|
||||||
|
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
|
||||||
|
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450).IsRequired();
|
||||||
|
entity.HasIndex(e => e.UserId);
|
||||||
|
entity.HasOne(e => e.User).WithMany()
|
||||||
|
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.Ignore(e => e.IsExpired);
|
||||||
|
entity.Ignore(e => e.IsUsed);
|
||||||
|
entity.Ignore(e => e.IsRevoked);
|
||||||
|
entity.Ignore(e => e.IsActive);
|
||||||
|
});
|
||||||
|
|
||||||
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
|
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
|
||||||
builder.Entity<AppUser>(entity =>
|
builder.Entity<AppUser>(entity =>
|
||||||
{
|
{
|
||||||
@@ -97,6 +120,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.NickName).HasMaxLength(100);
|
entity.Property(e => e.NickName).HasMaxLength(100);
|
||||||
entity.Property(e => e.FirstName_zh).HasMaxLength(100);
|
entity.Property(e => e.FirstName_zh).HasMaxLength(100);
|
||||||
entity.Property(e => e.LastName_zh).HasMaxLength(100);
|
entity.Property(e => e.LastName_zh).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.Entity).HasMaxLength(200);
|
||||||
entity.Property(e => e.Gender).HasMaxLength(10);
|
entity.Property(e => e.Gender).HasMaxLength(10);
|
||||||
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
|
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
|
||||||
entity.Property(e => e.Email).HasMaxLength(200);
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
@@ -178,6 +202,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
{
|
{
|
||||||
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.DefaultFunctionalClass).HasMaxLength(20).HasDefaultValue("Program");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Form990ExpenseLine (Part IX natural-expense line catalog) ─────────
|
||||||
|
builder.Entity<Form990ExpenseLine>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.LineCode).HasMaxLength(10).IsRequired();
|
||||||
|
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.HasIndex(e => e.LineCode).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
|
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
|
||||||
@@ -187,6 +223,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.HasOne(e => e.Form990Line).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
||||||
@@ -198,6 +236,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
|
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
|
||||||
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(e => e.Form990Line).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Expense ──────────────────────────────────────────────────────────
|
// ── Expense ──────────────────────────────────────────────────────────
|
||||||
@@ -226,12 +266,30 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
|
|
||||||
entity.HasOne(e => e.Ministry).WithMany()
|
entity.HasOne(e => e.Ministry).WithMany()
|
||||||
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(e => e.Member).WithMany()
|
||||||
|
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ExpenseLine (category breakdown of one Expense) ──────────────────
|
||||||
|
builder.Entity<ExpenseLine>(entity =>
|
||||||
|
{
|
||||||
|
// Mirror the parent Expense's soft-delete filter (required relationship).
|
||||||
|
entity.HasQueryFilter(l => !l.Expense!.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.Description).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.ExpenseId);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Expense).WithMany(x => x.Lines)
|
||||||
|
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Cascade);
|
||||||
entity.HasOne(e => e.CategoryGroup).WithMany()
|
entity.HasOne(e => e.CategoryGroup).WithMany()
|
||||||
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
|
||||||
entity.HasOne(e => e.SubCategory).WithMany()
|
entity.HasOne(e => e.SubCategory).WithMany()
|
||||||
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
entity.HasOne(e => e.Member).WithMany()
|
|
||||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ChurchProfile (singleton settings) ───────────────────────────────
|
// ── ChurchProfile (singleton settings) ───────────────────────────────
|
||||||
@@ -245,12 +303,43 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.BankName).HasMaxLength(200);
|
entity.Property(e => e.BankName).HasMaxLength(200);
|
||||||
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
||||||
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.NameZh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Phone).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Website).HasMaxLength(300);
|
||||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
// Optimistic-concurrency token for safe check-number allocation.
|
// Optimistic-concurrency token for safe check-number allocation.
|
||||||
entity.Property(e => e.xmin).IsRowVersion();
|
entity.Property(e => e.xmin).IsRowVersion();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── SiteSetting (singleton presentation/locale settings) ─────────────
|
||||||
|
builder.Entity<SiteSetting>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.SiteTitle).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.SiteTitleZh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.DefaultLanguage).HasMaxLength(10).IsRequired();
|
||||||
|
entity.Property(e => e.TimeZone).HasMaxLength(100).IsRequired();
|
||||||
|
entity.Property(e => e.DateFormat).HasMaxLength(50).IsRequired();
|
||||||
|
entity.Property(e => e.Currency).HasMaxLength(10).IsRequired();
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── NotificationSetting (singleton SMTP + Line settings) ─────────────
|
||||||
|
builder.Entity<NotificationSetting>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.SmtpHost).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.SmtpUser).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.SmtpPassword).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.FromAddress).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.FromName).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.LineChannelAccessToken).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.LineChannelSecret).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Check (disbursement) ─────────────────────────────────────────────
|
// ── Check (disbursement) ─────────────────────────────────────────────
|
||||||
builder.Entity<Check>(entity =>
|
builder.Entity<Check>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public static class DbSeeder
|
|||||||
("Hospitality", "招待", 8),
|
("Hospitality", "招待", 8),
|
||||||
("Children", "兒牧", 9),
|
("Children", "兒牧", 9),
|
||||||
("Catering", "餐飲", 10),
|
("Catering", "餐飲", 10),
|
||||||
|
("Cell Groups", "小組牧養", 11),
|
||||||
|
("Special Events", "特別活動", 12),
|
||||||
];
|
];
|
||||||
|
|
||||||
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
|
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
|
||||||
@@ -35,15 +37,115 @@ public static class DbSeeder
|
|||||||
[
|
[
|
||||||
("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]),
|
("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]),
|
||||||
("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]),
|
("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]),
|
||||||
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Consumables","消耗品")]),
|
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Disposable Tableware","一次性餐具")]),
|
||||||
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
|
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
|
||||||
("Materials", "教材", 5, [("Printing","印刷費用"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
|
("Materials", "教材", 5, [("Curriculum Printing","教材印刷"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
|
||||||
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾")]),
|
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾"),("Repairs & Maintenance","修繕維護")]),
|
||||||
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報")]),
|
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報"),("Advertising & Promotion","廣告推廣")]),
|
||||||
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Travel","差旅")]),
|
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Foreign Missions Support","國外宣教支援"),("Travel","差旅")]),
|
||||||
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
|
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
|
||||||
("Other", "其他", 10, [("Miscellaneous","雜支")]),
|
("Other", "其他", 10, [("Miscellaneous","雜支"),("Gifts","禮品")]),
|
||||||
("Personnel", "人事", 11, [("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
|
("Personnel", "人事", 11, [("Officer / Key Employee Compensation","主要職員薪酬"),("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Retirement / Pension","退休金"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
|
||||||
|
("Professional Services", "專業服務", 12, [("Legal","法律服務"),("Accounting & Audit","會計與審計"),("Other Professional","其他專業服務")]),
|
||||||
|
("Information Technology", "資訊科技", 13, [("Software & Subscriptions","軟體與訂閱"),("Website & Hosting","網站與主機"),("Internet & Telecom","網路與電信")]),
|
||||||
|
("Finance & Banking", "財務與銀行", 14, [("Interest","利息支出"),("Bank & Processing Fees","銀行/金流手續費")]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// (LineCode, Name_en, Name_zh, Sort)
|
||||||
|
private static readonly (string Code, string En, string Zh, int Sort)[] Form990LineSeed =
|
||||||
|
[
|
||||||
|
("1", "Grants to domestic organizations", "對國內機構之捐贈", 1),
|
||||||
|
("2", "Grants to domestic individuals", "對國內個人之捐贈", 2),
|
||||||
|
("3", "Grants to foreign organizations/individuals", "對國外之捐贈", 3),
|
||||||
|
("5", "Compensation of current officers / key employees", "主要職員/負責人薪酬", 4),
|
||||||
|
("7", "Other salaries and wages", "薪資", 5),
|
||||||
|
("8", "Pension plan accruals and contributions", "退休金提撥", 6),
|
||||||
|
("9", "Other employee benefits", "員工福利", 7),
|
||||||
|
("10", "Payroll taxes", "薪資稅", 8),
|
||||||
|
("11b", "Legal fees", "法律服務費", 9),
|
||||||
|
("11c", "Accounting fees", "會計與審計費", 10),
|
||||||
|
("11g", "Other fees for services (non-employee)", "其他勞務報酬(非員工)", 11),
|
||||||
|
("12", "Advertising and promotion", "廣告與推廣", 12),
|
||||||
|
("13", "Office expenses", "辦公費用", 13),
|
||||||
|
("14", "Information technology", "資訊科技", 14),
|
||||||
|
("16", "Occupancy", "場地佔用", 15),
|
||||||
|
("17", "Travel", "差旅", 16),
|
||||||
|
("19", "Conferences, conventions, and meetings", "會議與研習", 17),
|
||||||
|
("20", "Interest", "利息", 18),
|
||||||
|
("22", "Depreciation", "折舊", 19),
|
||||||
|
("23", "Insurance", "保險", 20),
|
||||||
|
("24", "Other expenses", "其他費用", 21),
|
||||||
|
];
|
||||||
|
|
||||||
|
// (GroupEn, SubEn, LineCode) — default natural-category → 990 line mapping.
|
||||||
|
private static readonly (string GroupEn, string SubEn, string Code)[] Form990SubMappingSeed =
|
||||||
|
[
|
||||||
|
("Personnel", "Officer / Key Employee Compensation", "5"),
|
||||||
|
("Personnel", "Salary & Wages", "7"),
|
||||||
|
("Personnel", "Payroll Taxes", "10"),
|
||||||
|
("Personnel", "Employee Benefits", "9"),
|
||||||
|
("Personnel", "Retirement / Pension","8"),
|
||||||
|
("Personnel", "Workers Compensation","9"),
|
||||||
|
("Personnel", "Honorarium", "11g"),
|
||||||
|
("Personnel", "Contract Labor", "11g"),
|
||||||
|
("Personnel", "Staff Training", "19"),
|
||||||
|
("Facility", "Rent", "16"),
|
||||||
|
("Facility", "Utilities", "16"),
|
||||||
|
("Facility", "Property Insurance", "23"),
|
||||||
|
("Facility", "Decoration", "24"),
|
||||||
|
// Building repairs & maintenance (plumbing, electrical, painting) are part of Occupancy.
|
||||||
|
("Facility", "Repairs & Maintenance", "16"),
|
||||||
|
("Training", "Course Fees", "19"),
|
||||||
|
("Training", "Conference", "19"),
|
||||||
|
("Training", "Books", "24"),
|
||||||
|
("Training", "Travel", "17"),
|
||||||
|
("Missions", "Travel", "17"),
|
||||||
|
// Domestic missions support is paid to individual missionaries/families → line 2 (grants to individuals).
|
||||||
|
("Missions", "Offering Transfer", "2"),
|
||||||
|
("Missions", "Missionary Support", "2"),
|
||||||
|
("Missions", "Foreign Missions Support", "3"),
|
||||||
|
("Benevolence", "Emergency Aid", "2"),
|
||||||
|
("Benevolence", "Condolence Gifts", "2"),
|
||||||
|
// Visitation is the church's own travel/program cost, not a grant to an individual.
|
||||||
|
("Benevolence", "Visit Expenses", "17"),
|
||||||
|
("Consumables", "Office Supplies", "13"),
|
||||||
|
// General supplies belong with office expenses (line 13), not the "Other" catch-all.
|
||||||
|
("Consumables", "Batteries", "13"),
|
||||||
|
("Consumables", "Accessories", "13"),
|
||||||
|
("Consumables", "Cleaning Supplies", "13"),
|
||||||
|
// IRS line 13 covers equipment rental and maintenance.
|
||||||
|
("Equipment", "Rental", "13"),
|
||||||
|
("Equipment", "Maintenance & Repair", "13"),
|
||||||
|
("Printing", "Bulletins", "13"),
|
||||||
|
("Printing", "Order of Service", "13"),
|
||||||
|
("Printing", "Posters", "12"),
|
||||||
|
("Printing", "Advertising & Promotion", "12"),
|
||||||
|
("Materials", "Curriculum Printing", "13"),
|
||||||
|
// Classroom/craft supplies fall under IRS line 13 office expenses ("supplies… classroom…").
|
||||||
|
("Materials", "Craft Supplies", "13"),
|
||||||
|
("Professional Services", "Legal", "11b"),
|
||||||
|
("Professional Services", "Accounting & Audit", "11c"),
|
||||||
|
("Professional Services", "Other Professional", "11g"),
|
||||||
|
("Information Technology", "Software & Subscriptions", "14"),
|
||||||
|
("Information Technology", "Website & Hosting", "14"),
|
||||||
|
("Information Technology", "Internet & Telecom", "14"),
|
||||||
|
("Finance & Banking", "Interest", "20"),
|
||||||
|
// Bank/processing fees are office expenses per IRS line 13 (consistent with Interest → 20).
|
||||||
|
("Finance & Banking", "Bank & Processing Fees", "13"),
|
||||||
|
// Appreciation/outreach gifts have no natural 990 line; mapped to 24 explicitly so this
|
||||||
|
// deliberate "Other" choice doesn't inflate UnmappedExpenseCount. (Benevolence gifts → line 2.)
|
||||||
|
("Other", "Gifts", "24"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// One-time corrections for subcategories that were mapped to the WRONG line in an earlier
|
||||||
|
// seed. The normal mapping loop below only fills NULLs, so it cannot fix an existing bad
|
||||||
|
// value — this block does. Idempotent: each row fires only while the subcategory still holds
|
||||||
|
// the OLD line, so it never clobbers a deliberate admin re-mapping. (GroupEn, SubEn, Old, New)
|
||||||
|
private static readonly (string GroupEn, string SubEn, string OldCode, string NewCode)[] Form990RemapSeed =
|
||||||
|
[
|
||||||
|
("Benevolence", "Visit Expenses", "2", "17"),
|
||||||
|
("Missions", "Missionary Support", "1", "2"),
|
||||||
|
("Missions", "Offering Transfer", "1", "2"),
|
||||||
];
|
];
|
||||||
|
|
||||||
private static readonly (string Name, string Description)[] Roles =
|
private static readonly (string Name, string Description)[] Roles =
|
||||||
@@ -87,6 +189,7 @@ public static class DbSeeder
|
|||||||
("finance", Modules.MonthlyStatements, true, true, false, true),
|
("finance", Modules.MonthlyStatements, true, true, false, true),
|
||||||
("finance", Modules.ChurchProfile, true, true, false, false),
|
("finance", Modules.ChurchProfile, true, true, false, false),
|
||||||
("finance", Modules.Disbursements, true, true, true, true),
|
("finance", Modules.Disbursements, true, true, true, true),
|
||||||
|
("finance", Modules.Form990Report, true, false, false, false),
|
||||||
|
|
||||||
// Logs — read-only. System logs are technical (pastor only); audit logs have
|
// Logs — read-only. System logs are technical (pastor only); audit logs have
|
||||||
// governance value, so finance and board members can read them too.
|
// governance value, so finance and board members can read them too.
|
||||||
@@ -94,6 +197,24 @@ public static class DbSeeder
|
|||||||
("pastor", Modules.AuditLogs, true, false, false, false),
|
("pastor", Modules.AuditLogs, true, false, false, false),
|
||||||
("finance", Modules.AuditLogs, true, false, false, false),
|
("finance", Modules.AuditLogs, true, false, false, false),
|
||||||
("board_member", Modules.AuditLogs, true, false, false, false),
|
("board_member", Modules.AuditLogs, true, false, false, false),
|
||||||
|
("pastor", Modules.Form990Report, true, false, false, false),
|
||||||
|
("board_member", Modules.Form990Report, true, false, false, false),
|
||||||
|
|
||||||
|
// Ministries — secretary maintains the list; coworker_chair edits; ministry
|
||||||
|
// leaders and pastor read.
|
||||||
|
("secretary", Modules.Ministries, true, true, true, false),
|
||||||
|
("coworker_chair", Modules.Ministries, true, true, false, false),
|
||||||
|
("ministry_leader", Modules.Ministries, true, false, false, false),
|
||||||
|
("pastor", Modules.Ministries, true, false, false, false),
|
||||||
|
|
||||||
|
// Meal attendance — secretary and coworkers record; finance and pastor read.
|
||||||
|
("secretary", Modules.MealAttendance, true, true, false, false),
|
||||||
|
("coworker", Modules.MealAttendance, true, true, false, false),
|
||||||
|
("finance", Modules.MealAttendance, true, false, false, false),
|
||||||
|
("pastor", Modules.MealAttendance, true, false, false, false),
|
||||||
|
|
||||||
|
// Users, Permissions, and Settings are intentionally super_admin-only:
|
||||||
|
// super_admin bypasses all checks, so no seed rows are needed here.
|
||||||
];
|
];
|
||||||
|
|
||||||
public static async Task SeedRolePermissionsAsync(AppDbContext db)
|
public static async Task SeedRolePermissionsAsync(AppDbContext db)
|
||||||
@@ -163,13 +284,35 @@ public static class DbSeeder
|
|||||||
foreach (var (en, zh, sort) in MinistrySeed)
|
foreach (var (en, zh, sort) in MinistrySeed)
|
||||||
{
|
{
|
||||||
if (!await db.Ministries.AnyAsync(m => m.Name_en == en))
|
if (!await db.Ministries.AnyAsync(m => m.Name_en == en))
|
||||||
db.Ministries.Add(new Ministry { Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true });
|
db.Ministries.Add(new Ministry
|
||||||
|
{
|
||||||
|
Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true,
|
||||||
|
DefaultFunctionalClass = en == "Administration"
|
||||||
|
? FunctionalClasses.ManagementGeneral
|
||||||
|
: FunctionalClasses.Program,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task SeedExpenseCategoriesAsync(AppDbContext db)
|
public static async Task SeedExpenseCategoriesAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
|
// One-time renames to remove same-name-different-parent ambiguity. Idempotent:
|
||||||
|
// only fires while the old name still exists. (New installs never hit this.)
|
||||||
|
var renames = new (string GroupEn, string OldSub, string NewEn, string NewZh)[]
|
||||||
|
{
|
||||||
|
("Food & Beverage", "Consumables", "Disposable Tableware", "一次性餐具"),
|
||||||
|
("Materials", "Printing", "Curriculum Printing", "教材印刷"),
|
||||||
|
};
|
||||||
|
foreach (var (groupEn, oldSub, newEn, newZh) in renames)
|
||||||
|
{
|
||||||
|
var grp = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == groupEn);
|
||||||
|
if (grp is null) continue;
|
||||||
|
var sub = await db.ExpenseSubCategories.FirstOrDefaultAsync(s => s.GroupId == grp.Id && s.Name_en == oldSub);
|
||||||
|
if (sub is not null) { sub.Name_en = newEn; sub.Name_zh = newZh; }
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed)
|
foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed)
|
||||||
{
|
{
|
||||||
var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);
|
var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);
|
||||||
@@ -192,6 +335,46 @@ public static class DbSeeder
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task SeedForm990ExpenseLinesAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
foreach (var (code, en, zh, sort) in Form990LineSeed)
|
||||||
|
{
|
||||||
|
if (!await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == code))
|
||||||
|
db.Form990ExpenseLines.Add(new Form990ExpenseLine
|
||||||
|
{ LineCode = code, Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true });
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var linesByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
|
||||||
|
var fallbackId = linesByCode["24"];
|
||||||
|
|
||||||
|
// Every group defaults to line 24 (safety net); precise mapping lives on subcategories.
|
||||||
|
foreach (var group in await db.ExpenseCategoryGroups.ToListAsync())
|
||||||
|
group.Form990LineId ??= fallbackId;
|
||||||
|
|
||||||
|
// Subcategory default mappings — only set when not already mapped (never clobber an admin edit).
|
||||||
|
var subsByKey = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
|
||||||
|
foreach (var (groupEn, subEn, code) in Form990SubMappingSeed)
|
||||||
|
{
|
||||||
|
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
|
||||||
|
if (sub is not null && sub.Form990LineId is null && linesByCode.TryGetValue(code, out var lineId))
|
||||||
|
sub.Form990LineId = lineId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct earlier mis-mappings on existing databases (see Form990RemapSeed). Only fires
|
||||||
|
// while the subcategory still holds the OLD line, so a later admin edit is never clobbered.
|
||||||
|
foreach (var (groupEn, subEn, oldCode, newCode) in Form990RemapSeed)
|
||||||
|
{
|
||||||
|
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
|
||||||
|
if (sub is null) continue;
|
||||||
|
if (linesByCode.TryGetValue(oldCode, out var oldId)
|
||||||
|
&& linesByCode.TryGetValue(newCode, out var newId)
|
||||||
|
&& sub.Form990LineId == oldId)
|
||||||
|
sub.Form990LineId = newId;
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task SeedChurchProfileAsync(AppDbContext db)
|
public static async Task SeedChurchProfileAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
// Singleton row used by the disbursement module (issuer info + check counter).
|
// Singleton row used by the disbursement module (issuer info + check counter).
|
||||||
@@ -208,6 +391,50 @@ public static class DbSeeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task SeedSiteSettingAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
// Singleton row holding site-wide presentation/locale settings.
|
||||||
|
if (!await db.SiteSettings.AnyAsync())
|
||||||
|
{
|
||||||
|
db.SiteSettings.Add(new SiteSetting
|
||||||
|
{
|
||||||
|
SiteTitle = "River Of Life Christian Church",
|
||||||
|
SiteTitleZh = "生命河靈糧堂",
|
||||||
|
DefaultLanguage = "en",
|
||||||
|
TimeZone = "America/Los_Angeles",
|
||||||
|
DateFormat = "yyyy-MM-dd",
|
||||||
|
Currency = "USD",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SeedNotificationSettingAsync(AppDbContext db, IConfiguration config)
|
||||||
|
{
|
||||||
|
// Singleton row that becomes the runtime source of truth for SMTP + Line. Seed it once
|
||||||
|
// from the legacy "Smtp"/"Line" appsettings sections so existing config carries over.
|
||||||
|
if (!await db.NotificationSettings.AnyAsync())
|
||||||
|
{
|
||||||
|
var smtp = config.GetSection("Smtp");
|
||||||
|
var line = config.GetSection("Line");
|
||||||
|
db.NotificationSettings.Add(new NotificationSetting
|
||||||
|
{
|
||||||
|
EnableEmail = !string.IsNullOrWhiteSpace(smtp["Host"]),
|
||||||
|
SmtpHost = smtp["Host"] ?? "",
|
||||||
|
SmtpPort = int.TryParse(smtp["Port"], out var port) ? port : 587,
|
||||||
|
SmtpUseSsl = !bool.TryParse(smtp["UseSsl"], out var ssl) || ssl,
|
||||||
|
SmtpUser = smtp["User"] ?? "",
|
||||||
|
SmtpPassword = smtp["Password"] ?? "",
|
||||||
|
FromAddress = smtp["FromAddress"] ?? "",
|
||||||
|
FromName = smtp["FromName"] ?? "",
|
||||||
|
EnableLine = !string.IsNullOrWhiteSpace(line["ChannelAccessToken"]),
|
||||||
|
LineChannelAccessToken = line["ChannelAccessToken"] ?? "",
|
||||||
|
LineChannelSecret = line["ChannelSecret"] ?? "",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds roles and (in Development) the default admin account.
|
/// Seeds roles and (in Development) the default admin account.
|
||||||
/// Called once on application startup after migrations have been applied.
|
/// Called once on application startup after migrations have been applied.
|
||||||
@@ -217,6 +444,7 @@ public static class DbSeeder
|
|||||||
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
|
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
|
||||||
var userManager = services.GetRequiredService<UserManager<AppUser>>();
|
var userManager = services.GetRequiredService<UserManager<AppUser>>();
|
||||||
var env = services.GetRequiredService<IWebHostEnvironment>();
|
var env = services.GetRequiredService<IWebHostEnvironment>();
|
||||||
|
var config = services.GetRequiredService<IConfiguration>();
|
||||||
|
|
||||||
await SeedRolesAsync(roleManager);
|
await SeedRolesAsync(roleManager);
|
||||||
|
|
||||||
@@ -225,7 +453,10 @@ public static class DbSeeder
|
|||||||
await SeedGivingCategoriesAsync(db);
|
await SeedGivingCategoriesAsync(db);
|
||||||
await SeedMinistriesAsync(db);
|
await SeedMinistriesAsync(db);
|
||||||
await SeedExpenseCategoriesAsync(db);
|
await SeedExpenseCategoriesAsync(db);
|
||||||
|
await SeedForm990ExpenseLinesAsync(db);
|
||||||
await SeedChurchProfileAsync(db);
|
await SeedChurchProfileAsync(db);
|
||||||
|
await SeedSiteSettingAsync(db);
|
||||||
|
await SeedNotificationSettingAsync(db, config);
|
||||||
|
|
||||||
if (env.IsDevelopment())
|
if (env.IsDevelopment())
|
||||||
await SeedAdminUserAsync(userManager);
|
await SeedAdminUserAsync(userManager);
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ rows AS (
|
|||||||
mi."Id" AS ministry_id,
|
mi."Id" AS ministry_id,
|
||||||
gp."Id" AS group_id,
|
gp."Id" AS group_id,
|
||||||
sc."Id" AS sub_id,
|
sc."Id" AS sub_id,
|
||||||
|
-- pre-allocate the expense id so the matching ExpenseLine can reference it
|
||||||
|
nextval(pg_get_serial_sequence('"Expenses"','Id')) AS new_id,
|
||||||
sp.is_reimb,
|
sp.is_reimb,
|
||||||
sp.vendor,
|
sp.vendor,
|
||||||
sp.descr,
|
sp.descr,
|
||||||
@@ -172,13 +174,14 @@ rows AS (
|
|||||||
JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp
|
JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp
|
||||||
JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id"
|
JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id"
|
||||||
)
|
)
|
||||||
|
, ins_exp AS (
|
||||||
INSERT INTO "Expenses"
|
INSERT INTO "Expenses"
|
||||||
("MinistryId","CategoryGroupId","SubCategoryId","Type","Status","Amount",
|
("Id","MinistryId","Type","Status","Amount",
|
||||||
"Description","VendorName","MemberId","CheckNumber","ExpenseDate",
|
"Description","VendorName","MemberId","CheckNumber","ExpenseDate",
|
||||||
"Notes","SubmittedBy","SubmittedAt","ReviewedBy","ReviewedAt","PaidBy","PaidAt",
|
"Notes","SubmittedBy","SubmittedAt","ReviewedBy","ReviewedAt","PaidBy","PaidAt",
|
||||||
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted")
|
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted")
|
||||||
SELECT
|
SELECT
|
||||||
r.ministry_id, r.group_id, r.sub_id,
|
r.new_id, r.ministry_id,
|
||||||
CASE WHEN r.is_reimb THEN 'StaffReimbursement' ELSE 'VendorPayment' END,
|
CASE WHEN r.is_reimb THEN 'StaffReimbursement' ELSE 'VendorPayment' END,
|
||||||
r.status,
|
r.status,
|
||||||
r.amount,
|
r.amount,
|
||||||
@@ -196,6 +199,15 @@ SELECT
|
|||||||
CASE WHEN r.status = 'Paid' THEN 'mockdata' END,
|
CASE WHEN r.status = 'Paid' THEN 'mockdata' END,
|
||||||
CASE WHEN r.status = 'Paid' THEN r.expense_date::timestamptz END,
|
CASE WHEN r.status = 'Paid' THEN r.expense_date::timestamptz END,
|
||||||
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata', false
|
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata', false
|
||||||
|
FROM rows r
|
||||||
|
)
|
||||||
|
-- one line per mock expense (single-category), mirroring the migrated production shape
|
||||||
|
INSERT INTO "ExpenseLines"
|
||||||
|
("ExpenseId","CategoryGroupId","SubCategoryId","FunctionalClass","Amount","Description",
|
||||||
|
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy")
|
||||||
|
SELECT
|
||||||
|
r.new_id, r.group_id, r.sub_id, NULL, r.amount, NULL,
|
||||||
|
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata'
|
||||||
FROM rows r;
|
FROM rows r;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ public class ChurchProfile : AuditableEntity, IAuditable
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
|
public string? NameZh { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Website { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ public class Expense : SoftDeleteEntity, IAuditable
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int MinistryId { get; set; }
|
public int MinistryId { get; set; }
|
||||||
public int CategoryGroupId { get; set; }
|
|
||||||
public int SubCategoryId { get; set; }
|
|
||||||
public string Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement
|
public string Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement
|
||||||
public string Status { get; set; } = "Draft"; // see state machine
|
public string Status { get; set; } = "Draft"; // see state machine
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; } // denormalized total = SUM(Lines.Amount), recomputed server-side
|
||||||
public string Description { get; set; } = null!;
|
public string Description { get; set; } = null!;
|
||||||
public string? VendorName { get; set; }
|
public string? VendorName { get; set; }
|
||||||
public int? MemberId { get; set; }
|
public int? MemberId { get; set; }
|
||||||
@@ -26,7 +24,6 @@ public class Expense : SoftDeleteEntity, IAuditable
|
|||||||
public string? PaidBy { get; set; }
|
public string? PaidBy { get; set; }
|
||||||
|
|
||||||
public Ministry? Ministry { get; set; }
|
public Ministry? Ministry { get; set; }
|
||||||
public ExpenseCategoryGroup? CategoryGroup { get; set; }
|
|
||||||
public ExpenseSubCategory? SubCategory { get; set; }
|
|
||||||
public Member? Member { get; set; }
|
public Member? Member { get; set; }
|
||||||
|
public List<ExpenseLine> Lines { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,8 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable
|
|||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public Form990ExpenseLine? Form990Line { get; set; }
|
||||||
|
|
||||||
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
|
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One category line of an <see cref="Expense"/>. A single invoice/payment can span
|
||||||
|
/// multiple expense categories, so the category / amount / functional-class axis lives
|
||||||
|
/// here per line; the Expense header keeps payment-level info and a denormalized total.
|
||||||
|
/// Lines are wholly owned by the header (replaced as a set on update, like CheckLine).
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseLine : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int ExpenseId { get; set; }
|
||||||
|
public int CategoryGroupId { get; set; }
|
||||||
|
public int SubCategoryId { get; set; }
|
||||||
|
public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string? Description { get; set; } // optional per-line note (header description is authoritative for check printing)
|
||||||
|
|
||||||
|
public Expense? Expense { get; set; }
|
||||||
|
public ExpenseCategoryGroup? CategoryGroup { get; set; }
|
||||||
|
public ExpenseSubCategory? SubCategory { get; set; }
|
||||||
|
}
|
||||||
@@ -10,5 +10,8 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable
|
|||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public Form990ExpenseLine? Form990Line { get; set; }
|
||||||
|
|
||||||
public ExpenseCategoryGroup? Group { get; set; }
|
public ExpenseCategoryGroup? Group { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>A row of IRS Form 990 Part IX (natural expense line), e.g. "7 — Other salaries and wages".</summary>
|
||||||
|
public class Form990ExpenseLine : AuditableEntity, IAuditable
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string LineCode { get; set; } = null!; // "7", "11b", "16", "24"
|
||||||
|
public string Name_en { get; set; } = null!;
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in
|
||||||
|
/// Ministry.DefaultFunctionalClass and ExpenseLine.FunctionalClass.
|
||||||
|
/// </summary>
|
||||||
|
public static class FunctionalClasses
|
||||||
|
{
|
||||||
|
public const string Program = "Program";
|
||||||
|
public const string ManagementGeneral = "ManagementGeneral";
|
||||||
|
public const string Fundraising = "Fundraising";
|
||||||
|
|
||||||
|
public static readonly IReadOnlyList<string> All = [Program, ManagementGeneral, Fundraising];
|
||||||
|
|
||||||
|
/// <summary>Returns the value if valid, otherwise Program (the safe default).</summary>
|
||||||
|
public static string Normalize(string? value) =>
|
||||||
|
value is not null && All.Contains(value) ? value : Program;
|
||||||
|
}
|
||||||
@@ -48,16 +48,20 @@ public static class AuditActions
|
|||||||
public const string PasswordChanged = "PasswordChanged";
|
public const string PasswordChanged = "PasswordChanged";
|
||||||
public const string UserDeactivated = "UserDeactivated";
|
public const string UserDeactivated = "UserDeactivated";
|
||||||
public const string PermissionChanged = "PermissionChanged";
|
public const string PermissionChanged = "PermissionChanged";
|
||||||
|
public const string InvitationCreated = "InvitationCreated";
|
||||||
|
public const string InvitationAccepted = "InvitationAccepted";
|
||||||
public const string CheckIssued = "CheckIssued";
|
public const string CheckIssued = "CheckIssued";
|
||||||
public const string CheckVoided = "CheckVoided";
|
public const string CheckVoided = "CheckVoided";
|
||||||
public const string ExpenseApproved = "ExpenseApproved";
|
public const string ExpenseApproved = "ExpenseApproved";
|
||||||
|
public const string ExpenseRejected = "ExpenseRejected";
|
||||||
public const string StatementFinalized = "StatementFinalized";
|
public const string StatementFinalized = "StatementFinalized";
|
||||||
|
|
||||||
public static readonly IReadOnlyList<string> All =
|
public static readonly IReadOnlyList<string> All =
|
||||||
[
|
[
|
||||||
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
||||||
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
|
PasswordChanged, UserDeactivated, PermissionChanged,
|
||||||
CheckVoided, ExpenseApproved, StatementFinalized,
|
InvitationCreated, InvitationAccepted, CheckIssued,
|
||||||
|
CheckVoided, ExpenseApproved, ExpenseRejected, StatementFinalized,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public class Member : SoftDeleteEntity, IAuditable
|
|||||||
public string? NickName { get; set; }
|
public string? NickName { get; set; }
|
||||||
public string? FirstName_zh { get; set; }
|
public string? FirstName_zh { get; set; }
|
||||||
public string? LastName_zh { get; set; }
|
public string? LastName_zh { get; set; }
|
||||||
|
public string? Entity { get; set; } // company / business name (公司行號) — used for company-check offerings
|
||||||
public string? Gender { get; set; } // 'M' | 'F' | 'Other'
|
public string? Gender { get; set; } // 'M' | 'F' | 'Other'
|
||||||
public DateOnly? DateOfBirth { get; set; }
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
public DateOnly? BaptismDate { get; set; }
|
public DateOnly? BaptismDate { get; set; }
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ public class Ministry : IAuditable
|
|||||||
public string? Description_zh { get; set; }
|
public string? Description_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
public string DefaultFunctionalClass { get; set; } = "Program";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton (Id == 1) holding the editable SMTP + Line notification settings. This row — not the
|
||||||
|
/// "Smtp"/"Line" appsettings sections — is the runtime source of truth; those sections only seed
|
||||||
|
/// this row once on first startup. Read at send time via <c>INotificationSettingsService</c> so
|
||||||
|
/// edits apply without restarting the API.
|
||||||
|
///
|
||||||
|
/// Secrets (<see cref="SmtpPassword"/>, <see cref="LineChannelAccessToken"/>,
|
||||||
|
/// <see cref="LineChannelSecret"/>) are stored plaintext and protected by RBAC (the <c>Settings</c>
|
||||||
|
/// module / super_admin) per the project decision for this small single-VM internal app.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationSetting : AuditableEntity, IAuditable
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
// ── Email (SMTP) ─────────────────────────────────────────────────────────
|
||||||
|
public bool EnableEmail { get; set; }
|
||||||
|
public string SmtpHost { get; set; } = "";
|
||||||
|
public int SmtpPort { get; set; } = 587;
|
||||||
|
public bool SmtpUseSsl { get; set; } = true; // true → STARTTLS
|
||||||
|
public string SmtpUser { get; set; } = "";
|
||||||
|
public string SmtpPassword { get; set; } = "";
|
||||||
|
public string FromAddress { get; set; } = "";
|
||||||
|
public string FromName { get; set; } = "";
|
||||||
|
|
||||||
|
// ── Line ─────────────────────────────────────────────────────────────────
|
||||||
|
public bool EnableLine { get; set; }
|
||||||
|
public string LineChannelAccessToken { get; set; } = "";
|
||||||
|
public string LineChannelSecret { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton (Id == 1) holding site-wide presentation and locale settings, edited from the
|
||||||
|
/// Church Profile → Site Settings tab (gated by the <c>Settings</c> permission module).
|
||||||
|
/// Seeded with sensible defaults on startup.
|
||||||
|
/// </summary>
|
||||||
|
public class SiteSetting : AuditableEntity, IAuditable
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string SiteTitle { get; set; } = "";
|
||||||
|
public string? SiteTitleZh { get; set; }
|
||||||
|
public string DefaultLanguage { get; set; } = "en"; // "en" | "zh"
|
||||||
|
public string TimeZone { get; set; } = "America/Los_Angeles";
|
||||||
|
public string DateFormat { get; set; } = "yyyy-MM-dd";
|
||||||
|
public string Currency { get; set; } = "USD";
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single-use, expiring invitation that lets a member set their own password and log in for
|
||||||
|
/// the first time — without an admin-generated temporary password. The raw token is e-mailed /
|
||||||
|
/// copied to the member; only its SHA-256 hash is stored here (same scheme as RefreshToken).
|
||||||
|
/// </summary>
|
||||||
|
public class UserInvitation
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public string UserId { get; set; } = null!;
|
||||||
|
public AppUser User { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>SHA-256 hex of the raw invitation token. Never store raw tokens.</summary>
|
||||||
|
public string TokenHash { get; set; } = null!;
|
||||||
|
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Id of the admin who generated the link.</summary>
|
||||||
|
public string CreatedBy { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Set when the member consumes the link to set their password (single-use).</summary>
|
||||||
|
public DateTime? UsedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set when superseded by a newer invitation for the same user (re-issue).</summary>
|
||||||
|
public DateTime? RevokedAt { get; set; }
|
||||||
|
|
||||||
|
// Computed helpers — NOT mapped to DB columns (ignored in OnModelCreating)
|
||||||
|
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
|
||||||
|
public bool IsUsed => UsedAt.HasValue;
|
||||||
|
public bool IsRevoked => RevokedAt.HasValue;
|
||||||
|
public bool IsActive => !IsUsed && !IsRevoked && !IsExpired;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUserInvitations : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UserInvitations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
UserId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||||
|
RevokedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UserInvitations", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_UserInvitations_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserInvitations_TokenHash",
|
||||||
|
table: "UserInvitations",
|
||||||
|
column: "TokenHash",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_UserInvitations_UserId",
|
||||||
|
table: "UserInvitations",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UserInvitations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2202
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddForm990FunctionalExpenses : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "DefaultFunctionalClass",
|
||||||
|
table: "Ministries",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "Program");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Form990LineId",
|
||||||
|
table: "ExpenseSubCategories",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "FunctionalClass",
|
||||||
|
table: "Expenses",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Form990LineId",
|
||||||
|
table: "ExpenseCategoryGroups",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Form990ExpenseLines",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
LineCode = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||||
|
Name_en = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Name_zh = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Form990ExpenseLines", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSubCategories_Form990LineId",
|
||||||
|
table: "ExpenseSubCategories",
|
||||||
|
column: "Form990LineId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseCategoryGroups_Form990LineId",
|
||||||
|
table: "ExpenseCategoryGroups",
|
||||||
|
column: "Form990LineId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Form990ExpenseLines_LineCode",
|
||||||
|
table: "Form990ExpenseLines",
|
||||||
|
column: "LineCode",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ExpenseCategoryGroups_Form990ExpenseLines_Form990LineId",
|
||||||
|
table: "ExpenseCategoryGroups",
|
||||||
|
column: "Form990LineId",
|
||||||
|
principalTable: "Form990ExpenseLines",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_ExpenseSubCategories_Form990ExpenseLines_Form990LineId",
|
||||||
|
table: "ExpenseSubCategories",
|
||||||
|
column: "Form990LineId",
|
||||||
|
principalTable: "Form990ExpenseLines",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ExpenseCategoryGroups_Form990ExpenseLines_Form990LineId",
|
||||||
|
table: "ExpenseCategoryGroups");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_ExpenseSubCategories_Form990ExpenseLines_Form990LineId",
|
||||||
|
table: "ExpenseSubCategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Form990ExpenseLines");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExpenseSubCategories_Form990LineId",
|
||||||
|
table: "ExpenseSubCategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ExpenseCategoryGroups_Form990LineId",
|
||||||
|
table: "ExpenseCategoryGroups");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultFunctionalClass",
|
||||||
|
table: "Ministries");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Form990LineId",
|
||||||
|
table: "ExpenseSubCategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FunctionalClass",
|
||||||
|
table: "Expenses");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Form990LineId",
|
||||||
|
table: "ExpenseCategoryGroups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -463,14 +463,26 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("NameZh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<int>("NextCheckNumber")
|
b.Property<int>("NextCheckNumber")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
b.Property<string>("State")
|
b.Property<string>("State")
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("character varying(50)");
|
||||||
@@ -483,6 +495,10 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Website")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
b.Property<string>("ZipCode")
|
b.Property<string>("ZipCode")
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
@@ -509,9 +525,6 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Property<decimal>("Amount")
|
b.Property<decimal>("Amount")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("CategoryGroupId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("CheckNumber")
|
b.Property<string>("CheckNumber")
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("character varying(50)");
|
||||||
@@ -580,9 +593,6 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasColumnType("character varying(30)")
|
.HasColumnType("character varying(30)")
|
||||||
.HasDefaultValue("Draft");
|
.HasDefaultValue("Draft");
|
||||||
|
|
||||||
b.Property<int>("SubCategoryId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@@ -609,8 +619,6 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("CategoryGroupId");
|
|
||||||
|
|
||||||
b.HasIndex("ExpenseDate");
|
b.HasIndex("ExpenseDate");
|
||||||
|
|
||||||
b.HasIndex("MemberId");
|
b.HasIndex("MemberId");
|
||||||
@@ -620,8 +628,6 @@ namespace ROLAC.API.Migrations
|
|||||||
b.HasIndex("Status")
|
b.HasIndex("Status")
|
||||||
.HasFilter("\"IsDeleted\" = false");
|
.HasFilter("\"IsDeleted\" = false");
|
||||||
|
|
||||||
b.HasIndex("SubCategoryId");
|
|
||||||
|
|
||||||
b.ToTable("Expenses");
|
b.ToTable("Expenses");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -641,6 +647,9 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<int?>("Form990LineId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -666,9 +675,66 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Form990LineId");
|
||||||
|
|
||||||
b.ToTable("ExpenseCategoryGroups");
|
b.ToTable("ExpenseCategoryGroups");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryGroupId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<int>("ExpenseId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("FunctionalClass")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("SubCategoryId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryGroupId");
|
||||||
|
|
||||||
|
b.HasIndex("ExpenseId");
|
||||||
|
|
||||||
|
b.HasIndex("SubCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("ExpenseLines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -685,6 +751,9 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<int?>("Form990LineId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int>("GroupId")
|
b.Property<int>("GroupId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -713,6 +782,8 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Form990LineId");
|
||||||
|
|
||||||
b.HasIndex("GroupId");
|
b.HasIndex("GroupId");
|
||||||
|
|
||||||
b.ToTable("ExpenseSubCategories");
|
b.ToTable("ExpenseSubCategories");
|
||||||
@@ -756,6 +827,58 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("FamilyUnits");
|
b.ToTable("FamilyUnits");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("LineCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("Name_en")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Name_zh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LineCode")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Form990ExpenseLines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.Giving", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.Giving", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1124,6 +1247,10 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Entity")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<int?>("FamilyUnitId")
|
b.Property<int?>("FamilyUnitId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -1225,6 +1352,13 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("DefaultFunctionalClass")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Program");
|
||||||
|
|
||||||
b.Property<string>("Description_en")
|
b.Property<string>("Description_en")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -1323,6 +1457,82 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("MonthlyStatements");
|
b.ToTable("MonthlyStatements");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.NotificationSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<bool>("EnableEmail")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("EnableLine")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("FromAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("FromName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("LineChannelAccessToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("LineChannelSecret")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SmtpHost")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SmtpPassword")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<int>("SmtpPort")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("SmtpUseSsl")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SmtpUser")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("NotificationSettings");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1653,6 +1863,109 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("RolePermissions");
|
b.ToTable("RolePermissions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.SiteSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("DateFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultLanguage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("SiteTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SiteTitleZh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("TimeZone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("SiteSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UsedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("UserInvitations");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||||
@@ -1735,12 +2048,6 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("CategoryGroupId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("ROLAC.API.Entities.Member", "Member")
|
b.HasOne("ROLAC.API.Entities.Member", "Member")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("MemberId")
|
.HasForeignKey("MemberId")
|
||||||
@@ -1752,6 +2059,35 @@ namespace ROLAC.API.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Member");
|
||||||
|
|
||||||
|
b.Navigation("Ministry");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("Form990LineId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("Form990Line");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseLine", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryGroupId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ROLAC.API.Entities.Expense", "Expense")
|
||||||
|
.WithMany("Lines")
|
||||||
|
.HasForeignKey("ExpenseId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory")
|
b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("SubCategoryId")
|
.HasForeignKey("SubCategoryId")
|
||||||
@@ -1760,21 +2096,26 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
b.Navigation("CategoryGroup");
|
b.Navigation("CategoryGroup");
|
||||||
|
|
||||||
b.Navigation("Member");
|
b.Navigation("Expense");
|
||||||
|
|
||||||
b.Navigation("Ministry");
|
|
||||||
|
|
||||||
b.Navigation("SubCategory");
|
b.Navigation("SubCategory");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
||||||
{
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("Form990LineId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "Group")
|
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "Group")
|
||||||
.WithMany("SubCategories")
|
.WithMany("SubCategories")
|
||||||
.HasForeignKey("GroupId")
|
.HasForeignKey("GroupId")
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Form990Line");
|
||||||
|
|
||||||
b.Navigation("Group");
|
b.Navigation("Group");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1874,6 +2215,17 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("Role");
|
b.Navigation("Role");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("RefreshTokens");
|
b.Navigation("RefreshTokens");
|
||||||
@@ -1884,6 +2236,11 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("SubCategories");
|
b.Navigation("SubCategories");
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
|
|||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<IMemberService, MemberService>();
|
builder.Services.AddScoped<IMemberService, MemberService>();
|
||||||
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||||||
|
builder.Services.AddScoped<IInvitationService, InvitationService>();
|
||||||
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
|
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
|
||||||
builder.Services.AddScoped<IGivingService, GivingService>();
|
builder.Services.AddScoped<IGivingService, GivingService>();
|
||||||
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
|
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
|
||||||
@@ -154,15 +155,21 @@ builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
|
|||||||
builder.Services.AddScoped<IExpenseService, ExpenseService>();
|
builder.Services.AddScoped<IExpenseService, ExpenseService>();
|
||||||
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
||||||
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
||||||
|
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>();
|
||||||
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
||||||
|
builder.Services.AddScoped<ISettingsService, SettingsService>();
|
||||||
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
||||||
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
||||||
|
|
||||||
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
|
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
|
||||||
|
// IOptions binding stays only as the one-time seed/fallback; the runtime source of truth is the
|
||||||
|
// DB-backed NotificationSetting row, read (and hot-reloaded) via INotificationSettingsService.
|
||||||
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
|
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
|
||||||
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
|
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
|
||||||
|
builder.Services.AddSingleton<ROLAC.API.Services.Notifications.INotificationSettingsService,
|
||||||
|
ROLAC.API.Services.Notifications.NotificationSettingsService>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
|
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
|
||||||
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
|
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
|
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
|
||||||
|
|||||||
@@ -60,6 +60,22 @@ public class AuthService : IAuthService
|
|||||||
throw new UnauthorizedAccessException("Account is inactive.");
|
throw new UnauthorizedAccessException("Account is inactive.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_audit.Write(
|
||||||
|
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
|
||||||
|
entityName: nameof(AppUser), entityId: user.Id,
|
||||||
|
summary: $"Login succeeded: {user.Email}",
|
||||||
|
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
|
||||||
|
|
||||||
|
return await IssueSessionAsync(user, ipAddress, deviceInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Issue session (shared by login and passwordless flows like invitations)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public async Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
|
||||||
|
AppUser user, string? ipAddress = null, string? deviceInfo = null)
|
||||||
|
{
|
||||||
var roles = await _userManager.GetRolesAsync(user);
|
var roles = await _userManager.GetRolesAsync(user);
|
||||||
var accessToken = _tokenService.GenerateAccessToken(user, roles);
|
var accessToken = _tokenService.GenerateAccessToken(user, roles);
|
||||||
var rawRefresh = _tokenService.GenerateRefreshToken();
|
var rawRefresh = _tokenService.GenerateRefreshToken();
|
||||||
@@ -79,12 +95,6 @@ public class AuthService : IAuthService
|
|||||||
await _userManager.UpdateAsync(user);
|
await _userManager.UpdateAsync(user);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
_audit.Write(
|
|
||||||
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
|
|
||||||
entityName: nameof(AppUser), entityId: user.Id,
|
|
||||||
summary: $"Login succeeded: {user.Email}",
|
|
||||||
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
|
|
||||||
|
|
||||||
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
|
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,5 +235,29 @@ public class AuthService : IAuthService
|
|||||||
Roles = roles,
|
Roles = roles,
|
||||||
LanguagePreference = user.LanguagePreference,
|
LanguagePreference = user.LanguagePreference,
|
||||||
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
|
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
|
||||||
|
MemberInfo = await BuildMemberInfoAsync(user),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the linked member's display fields, or null when the account has no
|
||||||
|
/// MemberId or its member record was soft-deleted (excluded by query filter).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<MemberInfo?> BuildMemberInfoAsync(AppUser user)
|
||||||
|
{
|
||||||
|
if (user.MemberId is not int memberId)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await _db.Members
|
||||||
|
.Where(member => member.Id == memberId)
|
||||||
|
.Select(member => new MemberInfo
|
||||||
|
{
|
||||||
|
Id = member.Id,
|
||||||
|
NickName = member.NickName,
|
||||||
|
FirstName_en = member.FirstName_en,
|
||||||
|
LastName_en = member.LastName_en,
|
||||||
|
FirstName_zh = member.FirstName_zh,
|
||||||
|
LastName_zh = member.LastName_zh,
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
var p = await GetOrCreateAsync();
|
var p = await GetOrCreateAsync();
|
||||||
return new ChurchProfileDto
|
return new ChurchProfileDto
|
||||||
{
|
{
|
||||||
Id = p.Id, Name = p.Name, Address = p.Address, City = p.City, State = p.State,
|
Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
|
||||||
|
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
|
||||||
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
||||||
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
|
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
|
||||||
};
|
};
|
||||||
@@ -24,7 +25,8 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
public async Task UpdateAsync(UpdateChurchProfileRequest r)
|
public async Task UpdateAsync(UpdateChurchProfileRequest r)
|
||||||
{
|
{
|
||||||
var p = await GetOrCreateAsync();
|
var p = await GetOrCreateAsync();
|
||||||
p.Name = r.Name; p.Address = r.Address; p.City = r.City; p.State = r.State;
|
p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
|
||||||
|
p.Website = r.Website; 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.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
|
||||||
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
|
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ public class DisbursementService : IDisbursementService
|
|||||||
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
|
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 members = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)).ToDictionaryAsync(m => m.Id);
|
||||||
|
|
||||||
|
// Category label per expense: the single line's category, or "Multiple" when it spans several.
|
||||||
|
var expenseIds = rows.Select(r => r.Id).ToList();
|
||||||
|
var lineGroups = await _db.ExpenseLines.AsNoTracking()
|
||||||
|
.Where(l => expenseIds.Contains(l.ExpenseId))
|
||||||
|
.OrderBy(l => l.Id)
|
||||||
|
.Select(l => new { l.ExpenseId, l.CategoryGroupId })
|
||||||
|
.ToListAsync();
|
||||||
|
var categoryByExpense = lineGroups.GroupBy(l => l.ExpenseId).ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.Select(l => l.CategoryGroupId).Distinct().Count() > 1
|
||||||
|
? "Multiple / 多類別"
|
||||||
|
: grpNames.GetValueOrDefault(g.First().CategoryGroupId, ""));
|
||||||
|
|
||||||
var groups = new Dictionary<string, PayeeGroupDto>();
|
var groups = new Dictionary<string, PayeeGroupDto>();
|
||||||
foreach (var e in rows)
|
foreach (var e in rows)
|
||||||
{
|
{
|
||||||
@@ -77,7 +90,7 @@ public class DisbursementService : IDisbursementService
|
|||||||
ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
||||||
Description = e.Description, Amount = e.Amount,
|
Description = e.Description, Amount = e.Amount,
|
||||||
MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
|
MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
|
||||||
CategoryName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""),
|
CategoryName = categoryByExpense.GetValueOrDefault(e.Id, ""),
|
||||||
});
|
});
|
||||||
g.TotalAmount += e.Amount;
|
g.TotalAmount += e.Amount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,21 +22,28 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
.OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en)
|
.OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
|
||||||
|
.ToDictionaryAsync(l => l.Id, l => l.LineCode);
|
||||||
|
|
||||||
return groups.Select(g => new ExpenseCategoryGroupDto
|
return groups.Select(g => new ExpenseCategoryGroupDto
|
||||||
{
|
{
|
||||||
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
|
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
|
||||||
SortOrder = g.SortOrder, IsActive = g.IsActive,
|
SortOrder = g.SortOrder, IsActive = g.IsActive,
|
||||||
|
Form990LineId = g.Form990LineId,
|
||||||
|
Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null,
|
||||||
SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto
|
SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto
|
||||||
{
|
{
|
||||||
Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
|
Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
|
||||||
SortOrder = s.SortOrder, IsActive = s.IsActive,
|
SortOrder = s.SortOrder, IsActive = s.IsActive,
|
||||||
|
Form990LineId = s.Form990LineId,
|
||||||
|
Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null,
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r)
|
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r)
|
||||||
{
|
{
|
||||||
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true };
|
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
|
||||||
_db.ExpenseCategoryGroups.Add(g);
|
_db.ExpenseCategoryGroups.Add(g);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return g.Id;
|
return g.Id;
|
||||||
@@ -46,7 +53,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var g = await _db.ExpenseCategoryGroups.FindAsync(id)
|
var g = await _db.ExpenseCategoryGroups.FindAsync(id)
|
||||||
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found.");
|
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found.");
|
||||||
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive;
|
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +69,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
|
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
|
||||||
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found.");
|
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found.");
|
||||||
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true };
|
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
|
||||||
_db.ExpenseSubCategories.Add(s);
|
_db.ExpenseSubCategories.Add(s);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return s.Id;
|
return s.Id;
|
||||||
@@ -72,7 +79,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var s = await _db.ExpenseSubCategories.FindAsync(id)
|
var s = await _db.ExpenseSubCategories.FindAsync(id)
|
||||||
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found.");
|
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found.");
|
||||||
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive;
|
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ public class ExpenseService : IExpenseService
|
|||||||
{
|
{
|
||||||
var query = _db.Expenses.AsNoTracking().AsQueryable();
|
var query = _db.Expenses.AsNoTracking().AsQueryable();
|
||||||
if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value);
|
if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value);
|
||||||
if (categoryGroupId.HasValue) query = query.Where(e => e.CategoryGroupId == categoryGroupId.Value);
|
// Category filters now match against any line of the expense.
|
||||||
if (subCategoryId.HasValue) query = query.Where(e => e.SubCategoryId == subCategoryId.Value);
|
if (categoryGroupId.HasValue) query = query.Where(e => e.Lines.Any(l => l.CategoryGroupId == categoryGroupId.Value));
|
||||||
|
if (subCategoryId.HasValue) query = query.Where(e => e.Lines.Any(l => l.SubCategoryId == subCategoryId.Value));
|
||||||
// `statuses` (comma-separated) takes precedence over single `status`; lets the dashboard
|
// `statuses` (comma-separated) takes precedence over single `status`; lets the dashboard
|
||||||
// request the Paid+Approved set in one call.
|
// request the Paid+Approved set in one call.
|
||||||
if (!string.IsNullOrWhiteSpace(statuses))
|
if (!string.IsNullOrWhiteSpace(statuses))
|
||||||
@@ -81,57 +82,139 @@ public class ExpenseService : IExpenseService
|
|||||||
|
|
||||||
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
|
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 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 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))
|
var memNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id))
|
||||||
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}");
|
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}");
|
||||||
|
var reviewerNames = await ResolveUserNamesAsync(rows.Select(r => r.ReviewedBy));
|
||||||
|
|
||||||
var items = rows.Select(e => new ExpenseListItemDto
|
// Line count + first line's category, per expense on this page.
|
||||||
|
var expenseIds = rows.Select(r => r.Id).ToList();
|
||||||
|
var lineRows = await _db.ExpenseLines.AsNoTracking()
|
||||||
|
.Where(l => expenseIds.Contains(l.ExpenseId))
|
||||||
|
.OrderBy(l => l.Id)
|
||||||
|
.Select(l => new { l.ExpenseId, l.CategoryGroupId })
|
||||||
|
.ToListAsync();
|
||||||
|
var linesByExpense = lineRows.GroupBy(l => l.ExpenseId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
var items = rows.Select(e =>
|
||||||
|
{
|
||||||
|
linesByExpense.TryGetValue(e.Id, out var ls);
|
||||||
|
var firstGroupId = ls is { Count: > 0 } ? ls[0].CategoryGroupId : 0;
|
||||||
|
return new ExpenseListItemDto
|
||||||
{
|
{
|
||||||
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
|
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
|
||||||
MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
|
MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
|
||||||
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""),
|
LineCount = ls?.Count ?? 0,
|
||||||
SubCategoryId = e.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(e.SubCategoryId, ""),
|
PrimaryCategoryName = grpNames.GetValueOrDefault(firstGroupId, ""),
|
||||||
VendorName = e.VendorName, MemberId = e.MemberId,
|
VendorName = e.VendorName, MemberId = e.MemberId,
|
||||||
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null,
|
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null,
|
||||||
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
||||||
HasReceipt = e.ReceiptBlobPath != null,
|
HasReceipt = e.ReceiptBlobPath != null,
|
||||||
CheckNumber = e.CheckNumber,
|
CheckNumber = e.CheckNumber,
|
||||||
|
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
|
||||||
|
ReviewedAt = e.ReviewedAt,
|
||||||
|
ReviewNotes = e.ReviewNotes,
|
||||||
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve actor user ids (AppUser.Id, stored in ReviewedBy/SubmittedBy/PaidBy) to a display name:
|
||||||
|
// the linked Member's full name when present, otherwise the account email.
|
||||||
|
private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
|
||||||
|
{
|
||||||
|
var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
|
||||||
|
if (ids.Count == 0) return new Dictionary<string, string>();
|
||||||
|
|
||||||
|
var users = await _db.Users.AsNoTracking()
|
||||||
|
.Where(u => ids.Contains(u.Id))
|
||||||
|
.Select(u => new { u.Id, u.Email, u.MemberId })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
|
||||||
|
var memberNames = await _db.Members.AsNoTracking()
|
||||||
|
.Where(m => memberIds.Contains(m.Id))
|
||||||
|
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());
|
||||||
|
|
||||||
|
return users.ToDictionary(
|
||||||
|
u => u.Id,
|
||||||
|
u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
|
||||||
|
? name
|
||||||
|
: (u.Email ?? u.Id));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ExpenseDto?> GetByIdAsync(int id)
|
public async Task<ExpenseDto?> GetByIdAsync(int id)
|
||||||
{
|
{
|
||||||
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (e is null) return null;
|
if (e is null) return null;
|
||||||
var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
|
var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
|
||||||
var grpName = await _db.ExpenseCategoryGroups.Where(g => g.Id == e.CategoryGroupId).Select(g => g.Name_en).FirstOrDefaultAsync() ?? "";
|
|
||||||
var subName = await _db.ExpenseSubCategories.Where(s => s.Id == e.SubCategoryId).Select(s => s.Name_en).FirstOrDefaultAsync() ?? "";
|
|
||||||
string? memName = e.MemberId != null
|
string? memName = e.MemberId != null
|
||||||
? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync()
|
? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
var reviewerName = e.ReviewedBy != null
|
||||||
|
? (await ResolveUserNamesAsync(new[] { e.ReviewedBy })).GetValueOrDefault(e.ReviewedBy)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var lines = await _db.ExpenseLines.AsNoTracking().Where(l => l.ExpenseId == id).OrderBy(l => l.Id).ToListAsync();
|
||||||
|
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 lineDtos = lines.Select(l => new ExpenseLineItemDto
|
||||||
|
{
|
||||||
|
Id = l.Id, CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""),
|
||||||
|
SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""),
|
||||||
|
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
return new ExpenseDto
|
return new ExpenseDto
|
||||||
{
|
{
|
||||||
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
|
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
|
||||||
MinistryId = e.MinistryId, MinistryName = minName,
|
MinistryId = e.MinistryId, MinistryName = minName,
|
||||||
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpName,
|
LineCount = lineDtos.Count,
|
||||||
SubCategoryId = e.SubCategoryId, SubCategoryName = subName,
|
PrimaryCategoryName = lineDtos.Count > 0 ? lineDtos[0].CategoryGroupName : "",
|
||||||
VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName,
|
VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName,
|
||||||
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
|
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
|
||||||
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
|
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
|
||||||
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt,
|
ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
|
||||||
|
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
|
||||||
|
Lines = lineDtos,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lines are the source of truth: ≥1 line, each with a category/subcategory and a positive amount.
|
||||||
|
private static void ValidateLines(List<ExpenseLineInput> lines)
|
||||||
|
{
|
||||||
|
if (lines is null || lines.Count == 0)
|
||||||
|
throw new InvalidOperationException("An expense must have at least one line.");
|
||||||
|
foreach (var l in lines)
|
||||||
|
{
|
||||||
|
if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0)
|
||||||
|
throw new InvalidOperationException("Each expense line needs a category group and subcategory.");
|
||||||
|
if (l.Amount <= 0)
|
||||||
|
throw new InvalidOperationException("Each expense line amount must be greater than zero.");
|
||||||
|
if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass))
|
||||||
|
throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ExpenseLine> BuildLines(List<ExpenseLineInput> inputs) =>
|
||||||
|
inputs.Select(l => new ExpenseLine
|
||||||
|
{
|
||||||
|
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
|
||||||
|
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
public async Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance)
|
public async Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance)
|
||||||
{
|
{
|
||||||
|
ValidateLines(r.Lines);
|
||||||
var e = new Expense
|
var e = new Expense
|
||||||
{
|
{
|
||||||
MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId,
|
MinistryId = r.MinistryId,
|
||||||
Type = r.Type, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName,
|
Type = r.Type, Amount = r.Lines.Sum(l => l.Amount), Description = r.Description, VendorName = r.VendorName,
|
||||||
CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
||||||
|
Lines = BuildLines(r.Lines),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (r.Type == "VendorPayment")
|
if (r.Type == "VendorPayment")
|
||||||
@@ -171,16 +254,21 @@ public class ExpenseService : IExpenseService
|
|||||||
|
|
||||||
public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance)
|
public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance)
|
||||||
{
|
{
|
||||||
|
ValidateLines(r.Lines);
|
||||||
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
|
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
|
||||||
var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id)
|
var e = await _db.Expenses.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
|
||||||
?? throw new KeyNotFoundException($"Expense {id} not found.");
|
?? throw new KeyNotFoundException($"Expense {id} not found.");
|
||||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
|
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
|
||||||
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements.");
|
throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements.");
|
||||||
|
|
||||||
e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId;
|
e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
||||||
e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
|
||||||
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
|
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
|
||||||
if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
|
if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
|
||||||
|
|
||||||
|
// Replace the line set wholesale (lines are owned by the header), recompute the total.
|
||||||
|
_db.ExpenseLines.RemoveRange(e.Lines);
|
||||||
|
e.Lines = BuildLines(r.Lines);
|
||||||
|
e.Amount = r.Lines.Sum(l => l.Amount);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,8 +291,11 @@ public class ExpenseService : IExpenseService
|
|||||||
{
|
{
|
||||||
var e = await RequireAsync(id);
|
var e = await RequireAsync(id);
|
||||||
if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement.");
|
if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement.");
|
||||||
if (e.Status != "Draft") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'.");
|
// Draft (first submit) or Rejected (re-submit after fixing the flagged issue, e.g. a clearer receipt).
|
||||||
|
if (e.Status != "Draft" && e.Status != "Rejected") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'.");
|
||||||
e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow;
|
e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow;
|
||||||
|
// Clear the prior review so the expense returns to a clean pending state.
|
||||||
|
e.ReviewedBy = null; e.ReviewedAt = null; e.ReviewNotes = null;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +318,11 @@ public class ExpenseService : IExpenseService
|
|||||||
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'.");
|
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'.");
|
||||||
e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes;
|
e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_audit.Write(
|
||||||
|
AuditActions.ExpenseRejected, AuditCategories.Business, LogLevelEnum.Information,
|
||||||
|
entityName: nameof(Expense), entityId: e.Id.ToString(),
|
||||||
|
summary: $"Expense #{e.Id} rejected: {e.Description} — {reviewNotes}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt)
|
public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt)
|
||||||
@@ -245,8 +341,8 @@ public class ExpenseService : IExpenseService
|
|||||||
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
|
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
|
||||||
{
|
{
|
||||||
var e = await RequireAsync(id);
|
var e = await RequireAsync(id);
|
||||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
|
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
|
||||||
throw new InvalidOperationException("You can only attach receipts to your own draft or pending reimbursements.");
|
throw new InvalidOperationException("You can only attach receipts to your own draft, pending, or rejected reimbursements.");
|
||||||
|
|
||||||
var safe = Path.GetFileName(fileName).Replace(' ', '_');
|
var safe = Path.GetFileName(fileName).Replace(' ', '_');
|
||||||
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
|
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
|
||||||
|
|||||||
@@ -54,16 +54,23 @@ public class FinanceDashboardService : IFinanceDashboardService
|
|||||||
{
|
{
|
||||||
var q = PaidApproved(from, to);
|
var q = PaidApproved(from, to);
|
||||||
if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value);
|
if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value);
|
||||||
if (categoryGroupId.HasValue) q = q.Where(e => e.CategoryGroupId == categoryGroupId.Value);
|
|
||||||
|
|
||||||
// Group by the deepest level whose parent id is supplied.
|
// Lines belonging to the scoped (Paid+Approved, optionally ministry-filtered) expenses.
|
||||||
|
var scopedLines = from l in _db.ExpenseLines
|
||||||
|
join e in q on l.ExpenseId equals e.Id
|
||||||
|
select l;
|
||||||
|
|
||||||
|
// Group by the deepest level whose parent id is supplied. Category levels aggregate
|
||||||
|
// over LINES (line amounts); the ministry level uses the header total to avoid
|
||||||
|
// double-counting a multi-line expense across its lines.
|
||||||
List<(int Id, decimal Amount)> grouped;
|
List<(int Id, decimal Amount)> grouped;
|
||||||
if (categoryGroupId.HasValue)
|
if (categoryGroupId.HasValue)
|
||||||
grouped = (await q.GroupBy(e => e.SubCategoryId)
|
grouped = (await scopedLines.Where(l => l.CategoryGroupId == categoryGroupId.Value)
|
||||||
|
.GroupBy(l => l.SubCategoryId)
|
||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
||||||
.Select(x => (x.Id, x.Amount)).ToList();
|
.Select(x => (x.Id, x.Amount)).ToList();
|
||||||
else if (ministryId.HasValue)
|
else if (ministryId.HasValue)
|
||||||
grouped = (await q.GroupBy(e => e.CategoryGroupId)
|
grouped = (await scopedLines.GroupBy(l => l.CategoryGroupId)
|
||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
||||||
.Select(x => (x.Id, x.Amount)).ToList();
|
.Select(x => (x.Id, x.Amount)).ToList();
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Finance;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional
|
||||||
|
/// Expenses. Expense scope matches FinanceDashboardService: Paid + Approved only.
|
||||||
|
/// Each expense line is categorized independently, so one invoice can span multiple lines.
|
||||||
|
/// </summary>
|
||||||
|
public class Form990ReportService : IForm990ReportService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public Form990ReportService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<List<Form990ExpenseLineDto>> GetLinesAsync() =>
|
||||||
|
await _db.Form990ExpenseLines.AsNoTracking().Where(l => l.IsActive)
|
||||||
|
.OrderBy(l => l.SortOrder)
|
||||||
|
.Select(l => new Form990ExpenseLineDto
|
||||||
|
{
|
||||||
|
Id = l.Id,
|
||||||
|
LineCode = l.LineCode,
|
||||||
|
Name_en = l.Name_en,
|
||||||
|
Name_zh = l.Name_zh,
|
||||||
|
SortOrder = l.SortOrder,
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<FunctionalExpenseStatementDto> GetFunctionalExpenseStatementAsync(DateOnly? from, DateOnly? to)
|
||||||
|
{
|
||||||
|
var lines = await _db.Form990ExpenseLines.AsNoTracking()
|
||||||
|
.Where(l => l.IsActive).OrderBy(l => l.SortOrder).ToListAsync();
|
||||||
|
var fallbackId = lines.FirstOrDefault(l => l.LineCode == "24")?.Id;
|
||||||
|
|
||||||
|
var expenses = _db.Expenses.Where(e => e.Status == "Paid" || e.Status == "Approved");
|
||||||
|
if (from.HasValue) expenses = expenses.Where(e => e.ExpenseDate >= from.Value);
|
||||||
|
if (to.HasValue) expenses = expenses.Where(e => e.ExpenseDate <= to.Value);
|
||||||
|
|
||||||
|
var rows = await (
|
||||||
|
from e in expenses
|
||||||
|
join l in _db.ExpenseLines on e.Id equals l.ExpenseId
|
||||||
|
join m in _db.Ministries on e.MinistryId equals m.Id
|
||||||
|
join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id
|
||||||
|
join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
l.Amount,
|
||||||
|
l.FunctionalClass,
|
||||||
|
MinistryDefault = m.DefaultFunctionalClass,
|
||||||
|
SubLineId = sub.Form990LineId,
|
||||||
|
GroupLineId = grp.Form990LineId,
|
||||||
|
}).ToListAsync();
|
||||||
|
|
||||||
|
var acc = new Dictionary<int, (decimal P, decimal M, decimal F)>();
|
||||||
|
var unmapped = 0;
|
||||||
|
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
var function = FunctionalClasses.Normalize(r.FunctionalClass ?? r.MinistryDefault);
|
||||||
|
var lineId = r.SubLineId ?? r.GroupLineId ?? fallbackId;
|
||||||
|
if (lineId is null) continue;
|
||||||
|
|
||||||
|
if (r.SubLineId is null) unmapped++;
|
||||||
|
|
||||||
|
var cur = acc.GetValueOrDefault(lineId.Value);
|
||||||
|
acc[lineId.Value] = function switch
|
||||||
|
{
|
||||||
|
FunctionalClasses.ManagementGeneral => (cur.P, cur.M + r.Amount, cur.F),
|
||||||
|
FunctionalClasses.Fundraising => (cur.P, cur.M, cur.F + r.Amount),
|
||||||
|
_ => (cur.P + r.Amount, cur.M, cur.F),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var dto = new FunctionalExpenseStatementDto { UnmappedExpenseCount = unmapped };
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var v = acc.GetValueOrDefault(line.Id);
|
||||||
|
dto.Rows.Add(new FunctionalExpenseRowDto
|
||||||
|
{
|
||||||
|
LineCode = line.LineCode, Name_en = line.Name_en, Name_zh = line.Name_zh,
|
||||||
|
Program = v.P, ManagementGeneral = v.M, Fundraising = v.F, Total = v.P + v.M + v.F,
|
||||||
|
});
|
||||||
|
dto.ProgramTotal += v.P;
|
||||||
|
dto.ManagementGeneralTotal += v.M;
|
||||||
|
dto.FundraisingTotal += v.F;
|
||||||
|
}
|
||||||
|
dto.GrandTotal = dto.ProgramTotal + dto.ManagementGeneralTotal + dto.FundraisingTotal;
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,16 @@ public interface IAuthService
|
|||||||
string rawRefreshToken,
|
string rawRefreshToken,
|
||||||
string? ipAddress = null);
|
string? ipAddress = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues a fresh access token + refresh token for an already-verified user (no password
|
||||||
|
/// check). Stores the refresh token and returns the raw value for the caller to put in the
|
||||||
|
/// HttpOnly cookie. Used by passwordless flows such as accepting an invitation link.
|
||||||
|
/// </summary>
|
||||||
|
Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
|
||||||
|
AppUser user,
|
||||||
|
string? ipAddress = null,
|
||||||
|
string? deviceInfo = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Revokes the refresh token identified by its raw value.
|
/// Revokes the refresh token identified by its raw value.
|
||||||
/// Silently succeeds if the token is not found.
|
/// Silently succeeds if the token is not found.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using ROLAC.API.DTOs.Finance;
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IForm990ReportService
|
||||||
|
{
|
||||||
|
Task<FunctionalExpenseStatementDto> GetFunctionalExpenseStatementAsync(DateOnly? from, DateOnly? to);
|
||||||
|
Task<List<Form990ExpenseLineDto>> GetLinesAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using ROLAC.API.DTOs.Invitations;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IInvitationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a single-use, 7-day invitation link for a member. Auto-creates the member's
|
||||||
|
/// login account (no password) when none exists, and revokes any prior unused invitation for
|
||||||
|
/// that account. Returns the raw token (shown once) and its expiry.
|
||||||
|
/// Throws <see cref="InvalidOperationException"/> when the member is missing or has no email.
|
||||||
|
/// </summary>
|
||||||
|
Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request);
|
||||||
|
|
||||||
|
/// <summary>Checks whether a raw token is still usable, without mutating it.</summary>
|
||||||
|
Task<ValidateInvitationResult> ValidateAsync(string rawToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Consumes an invitation: validates the token, sets the account password (enforcing the
|
||||||
|
/// Identity policy), and marks the invitation used. Returns the account on success, or an
|
||||||
|
/// error message describing why it failed (invalid/expired/used token or a policy violation).
|
||||||
|
/// </summary>
|
||||||
|
Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword);
|
||||||
|
|
||||||
|
/// <summary>E-mails an already-generated invitation link to the member via IEmailService.</summary>
|
||||||
|
Task SendEmailAsync(int memberId, string link);
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@ public interface IMealAttendanceService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value);
|
Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overwrites all three age-group columns for <paramref name="date"/> with absolute
|
||||||
|
/// values (each clamped at zero), creating the row if it does not exist, and returns
|
||||||
|
/// the resulting authoritative counts. Used by the back-office Sunday-attendance editor.
|
||||||
|
/// </summary>
|
||||||
|
Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid);
|
||||||
|
|
||||||
/// <summary>Returns the daily counts within the inclusive date range, ordered by date (for the dashboard).</summary>
|
/// <summary>Returns the daily counts within the inclusive date range, ordered by date (for the dashboard).</summary>
|
||||||
Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to);
|
Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,4 +4,7 @@ namespace ROLAC.API.Services;
|
|||||||
public interface IMinistryService
|
public interface IMinistryService
|
||||||
{
|
{
|
||||||
Task<List<MinistryDto>> GetAllAsync(bool includeInactive);
|
Task<List<MinistryDto>> GetAllAsync(bool includeInactive);
|
||||||
|
Task<int> CreateAsync(CreateMinistryRequest request);
|
||||||
|
Task UpdateAsync(int id, UpdateMinistryRequest request);
|
||||||
|
Task DeactivateAsync(int id); // soft-disable: IsActive = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using ROLAC.API.DTOs.Settings;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads and writes the singleton SiteSetting and NotificationSetting rows. Notification secrets
|
||||||
|
/// are masked on read and treated as write-only on update (blank = keep). After a notification
|
||||||
|
/// update the runtime cache is reloaded so changes apply without an API restart.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISettingsService
|
||||||
|
{
|
||||||
|
Task<SiteSettingDto> GetSiteAsync();
|
||||||
|
Task UpdateSiteAsync(UpdateSiteSettingRequest request);
|
||||||
|
|
||||||
|
Task<NotificationSettingDto> GetNotificationAsync();
|
||||||
|
Task UpdateNotificationAsync(UpdateNotificationSettingRequest request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Invitations;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public class InvitationService : IInvitationService
|
||||||
|
{
|
||||||
|
/// <summary>Lifetime of a freshly issued invitation link.</summary>
|
||||||
|
private const int InvitationLifetimeDays = 7;
|
||||||
|
|
||||||
|
private readonly UserManager<AppUser> _userManager;
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITokenService _tokenService;
|
||||||
|
private readonly IEmailService _emailService;
|
||||||
|
private readonly IAuditLogger _audit;
|
||||||
|
private readonly CurrentUserAccessor _currentUser;
|
||||||
|
|
||||||
|
public InvitationService(
|
||||||
|
UserManager<AppUser> userManager,
|
||||||
|
AppDbContext db,
|
||||||
|
ITokenService tokenService,
|
||||||
|
IEmailService emailService,
|
||||||
|
IAuditLogger audit,
|
||||||
|
CurrentUserAccessor currentUser)
|
||||||
|
{
|
||||||
|
_userManager = userManager;
|
||||||
|
_db = db;
|
||||||
|
_tokenService = tokenService;
|
||||||
|
_emailService = emailService;
|
||||||
|
_audit = audit;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request)
|
||||||
|
{
|
||||||
|
var member = await _db.Members.FindAsync(request.MemberId)
|
||||||
|
?? throw new InvalidOperationException($"Member {request.MemberId} does not exist.");
|
||||||
|
|
||||||
|
var email = (request.Email ?? member.Email)?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"This member has no email address. Add an email before creating an invitation.");
|
||||||
|
|
||||||
|
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.MemberId == request.MemberId);
|
||||||
|
if (user is null)
|
||||||
|
user = await CreateAccountAsync(member, email, request.Roles);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Re-issue: revoke any prior unused invitation so only one link is ever live.
|
||||||
|
var existing = await _db.UserInvitations
|
||||||
|
.Where(invitation => invitation.UserId == user.Id
|
||||||
|
&& invitation.UsedAt == null
|
||||||
|
&& invitation.RevokedAt == null)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var invitation in existing)
|
||||||
|
invitation.RevokedAt = now;
|
||||||
|
|
||||||
|
var rawToken = GenerateRawToken();
|
||||||
|
var expiresAt = now.AddDays(InvitationLifetimeDays);
|
||||||
|
|
||||||
|
_db.UserInvitations.Add(new UserInvitation
|
||||||
|
{
|
||||||
|
UserId = user.Id,
|
||||||
|
TokenHash = _tokenService.HashToken(rawToken),
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
CreatedAt = now,
|
||||||
|
CreatedBy = _currentUser.UserIdOrSystem,
|
||||||
|
});
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_audit.Write(
|
||||||
|
AuditActions.InvitationCreated, AuditCategories.Security, LogLevelEnum.Information,
|
||||||
|
entityName: nameof(AppUser), entityId: user.Id,
|
||||||
|
summary: $"Invitation link created for {user.Email}");
|
||||||
|
|
||||||
|
return new CreateInvitationResult { Token = rawToken, ExpiresAt = expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a passwordless login account linked to the member; mirrors UserManagementService.</summary>
|
||||||
|
private async Task<AppUser> CreateAccountAsync(Member member, string email, List<string>? roles)
|
||||||
|
{
|
||||||
|
if (await _userManager.FindByEmailAsync(email) is not null)
|
||||||
|
throw new InvalidOperationException($"Email '{email}' is already in use by another account.");
|
||||||
|
|
||||||
|
var user = new AppUser
|
||||||
|
{
|
||||||
|
UserName = email,
|
||||||
|
Email = email,
|
||||||
|
EmailConfirmed = true,
|
||||||
|
MemberId = member.Id,
|
||||||
|
LanguagePreference = member.LanguagePreference,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
// No-password overload: the member sets their own password via the invitation link.
|
||||||
|
var result = await _userManager.CreateAsync(user);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
string.Join("; ", result.Errors.Select(error => error.Description)));
|
||||||
|
|
||||||
|
var rolesToAssign = roles is { Count: > 0 } ? roles : new List<string> { "member" };
|
||||||
|
await _userManager.AddToRolesAsync(user, rolesToAssign);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validate ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<ValidateInvitationResult> ValidateAsync(string rawToken)
|
||||||
|
{
|
||||||
|
var invitation = await FindByRawTokenAsync(rawToken);
|
||||||
|
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
|
||||||
|
return new ValidateInvitationResult { Valid = false, Expired = false };
|
||||||
|
if (invitation.IsExpired)
|
||||||
|
return new ValidateInvitationResult { Valid = false, Expired = true };
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(invitation.UserId);
|
||||||
|
return new ValidateInvitationResult
|
||||||
|
{
|
||||||
|
Valid = true,
|
||||||
|
Expired = false,
|
||||||
|
Email = user?.Email,
|
||||||
|
MemberName = await ResolveMemberNameAsync(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Accept ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword)
|
||||||
|
{
|
||||||
|
var invitation = await FindByRawTokenAsync(rawToken);
|
||||||
|
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
|
||||||
|
return (null, "This invitation link is invalid or has already been used.");
|
||||||
|
if (invitation.IsExpired)
|
||||||
|
return (null, "This invitation link has expired. Please ask for a new one.");
|
||||||
|
|
||||||
|
var user = await _userManager.FindByIdAsync(invitation.UserId);
|
||||||
|
if (user is null)
|
||||||
|
return (null, "The account for this invitation no longer exists.");
|
||||||
|
|
||||||
|
// Set the password — works whether or not one already exists, and enforces the policy.
|
||||||
|
var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
|
||||||
|
var result = await _userManager.ResetPasswordAsync(user, resetToken, newPassword);
|
||||||
|
if (!result.Succeeded)
|
||||||
|
return (null, string.Join(" ", result.Errors.Select(error => error.Description)));
|
||||||
|
|
||||||
|
invitation.UsedAt = DateTime.UtcNow;
|
||||||
|
user.EmailConfirmed = true;
|
||||||
|
user.IsActive = true;
|
||||||
|
await _userManager.UpdateAsync(user);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_audit.Write(
|
||||||
|
AuditActions.InvitationAccepted, AuditCategories.Security, LogLevelEnum.Information,
|
||||||
|
entityName: nameof(AppUser), entityId: user.Id,
|
||||||
|
summary: $"Invitation accepted — password set for {user.Email}",
|
||||||
|
userId: user.Id, userEmail: user.Email);
|
||||||
|
|
||||||
|
return (user, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send email ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task SendEmailAsync(int memberId, string link)
|
||||||
|
{
|
||||||
|
var member = await _db.Members.FindAsync(memberId)
|
||||||
|
?? throw new InvalidOperationException($"Member {memberId} does not exist.");
|
||||||
|
|
||||||
|
var name = WebUtility.HtmlEncode(member.NickName ?? member.FirstName_en);
|
||||||
|
var safeLink = WebUtility.HtmlEncode(link);
|
||||||
|
var subject = "Your River Of Life Christian Church account invitation";
|
||||||
|
var htmlBody =
|
||||||
|
$"<p>Hi {name},</p>" +
|
||||||
|
"<p>You've been invited to set up your account for the River Of Life Christian Church portal.</p>" +
|
||||||
|
$"<p>Click the link below to set your password and sign in. This link expires in {InvitationLifetimeDays} days and can only be used once.</p>" +
|
||||||
|
$"<p><a href=\"{safeLink}\">Set your password and sign in</a></p>" +
|
||||||
|
"<p>If the button doesn't work, copy and paste this address into your browser:</p>" +
|
||||||
|
$"<p>{safeLink}</p>";
|
||||||
|
|
||||||
|
var result = await _emailService.SendAsync(new EmailMessage(
|
||||||
|
MemberIds: new[] { memberId },
|
||||||
|
Addresses: Array.Empty<string>(),
|
||||||
|
Subject: subject,
|
||||||
|
HtmlBody: htmlBody));
|
||||||
|
|
||||||
|
if (result.SentCount == 0)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
result.Failures.Count > 0
|
||||||
|
? $"Failed to send email: {result.Failures[0].Error}"
|
||||||
|
: "No email address on file for this member.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Task<UserInvitation?> FindByRawTokenAsync(string rawToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawToken))
|
||||||
|
return Task.FromResult<UserInvitation?>(null);
|
||||||
|
|
||||||
|
var hash = _tokenService.HashToken(rawToken);
|
||||||
|
return _db.UserInvitations.FirstOrDefaultAsync(invitation => invitation.TokenHash == hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> ResolveMemberNameAsync(AppUser? user)
|
||||||
|
{
|
||||||
|
if (user?.MemberId is not int memberId)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await _db.Members
|
||||||
|
.Where(member => member.Id == memberId)
|
||||||
|
.Select(member => (member.NickName ?? member.FirstName_en) + " " + member.LastName_en)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>32 cryptographically-random bytes as a URL-safe base64 string.</summary>
|
||||||
|
private static string GenerateRawToken()
|
||||||
|
{
|
||||||
|
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||||
|
return Convert.ToBase64String(bytes)
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_')
|
||||||
|
.TrimEnd('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,26 @@ public class MealAttendanceService : IMealAttendanceService
|
|||||||
return await ReadAsync(date);
|
return await ReadAsync(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid)
|
||||||
|
{
|
||||||
|
// Single-editor back-office path, so a tracked load + SaveChanges is fine here; no need for the
|
||||||
|
// race-safe EnsureRowAsync + ExecuteUpdateAsync pattern, which the EF InMemory test provider can't run.
|
||||||
|
var row = await _db.MealAttendances.FirstOrDefaultAsync(a => a.AttendanceDate == date);
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
row = new MealAttendance { AttendanceDate = date };
|
||||||
|
_db.MealAttendances.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts can never be negative; clamp before writing.
|
||||||
|
row.AdultCount = adult < 0 ? 0 : adult;
|
||||||
|
row.YouthCount = youth < 0 ? 0 : youth;
|
||||||
|
row.KidCount = kid < 0 ? 0 : kid;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return ToDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to)
|
public async Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to)
|
||||||
{
|
{
|
||||||
var rows = await _db.MealAttendances.AsNoTracking()
|
var rows = await _db.MealAttendances.AsNoTracking()
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public class MemberService : IMemberService
|
|||||||
(m.NickName != null && m.NickName.ToLower().Contains(s)) ||
|
(m.NickName != null && m.NickName.ToLower().Contains(s)) ||
|
||||||
(m.FirstName_zh != null && m.FirstName_zh.Contains(search)) ||
|
(m.FirstName_zh != null && m.FirstName_zh.Contains(search)) ||
|
||||||
(m.LastName_zh != null && m.LastName_zh.Contains(search)) ||
|
(m.LastName_zh != null && m.LastName_zh.Contains(search)) ||
|
||||||
|
(m.Entity != null && m.Entity.ToLower().Contains(s)) ||
|
||||||
(m.Email != null && m.Email.ToLower().Contains(s)));
|
(m.Email != null && m.Email.ToLower().Contains(s)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ public class MemberService : IMemberService
|
|||||||
NickName = m.NickName,
|
NickName = m.NickName,
|
||||||
FirstName_zh = m.FirstName_zh,
|
FirstName_zh = m.FirstName_zh,
|
||||||
LastName_zh = m.LastName_zh,
|
LastName_zh = m.LastName_zh,
|
||||||
|
Entity = m.Entity,
|
||||||
Status = m.Status,
|
Status = m.Status,
|
||||||
Email = m.Email,
|
Email = m.Email,
|
||||||
PhoneCell = m.PhoneCell,
|
PhoneCell = m.PhoneCell,
|
||||||
@@ -105,6 +107,7 @@ public class MemberService : IMemberService
|
|||||||
{
|
{
|
||||||
Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en,
|
Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en,
|
||||||
NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh,
|
NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh,
|
||||||
|
Entity = m.Entity,
|
||||||
Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate,
|
Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate,
|
||||||
BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell,
|
BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell,
|
||||||
PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State,
|
PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State,
|
||||||
@@ -157,6 +160,7 @@ public class MemberService : IMemberService
|
|||||||
{
|
{
|
||||||
FirstName_en = r.FirstName_en, LastName_en = r.LastName_en,
|
FirstName_en = r.FirstName_en, LastName_en = r.LastName_en,
|
||||||
NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh,
|
NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh,
|
||||||
|
Entity = r.Entity,
|
||||||
Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate,
|
Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate,
|
||||||
BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell,
|
BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell,
|
||||||
PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State,
|
PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State,
|
||||||
@@ -169,6 +173,7 @@ public class MemberService : IMemberService
|
|||||||
{
|
{
|
||||||
m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en;
|
m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en;
|
||||||
m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh;
|
m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh;
|
||||||
|
m.Entity = r.Entity;
|
||||||
m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate;
|
m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate;
|
||||||
m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell;
|
m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell;
|
||||||
m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State;
|
m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
using ROLAC.API.DTOs.Ministry;
|
using ROLAC.API.DTOs.Ministry;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
namespace ROLAC.API.Services;
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -18,8 +19,43 @@ public class MinistryService : IMinistryService
|
|||||||
.Select(m => new MinistryDto
|
.Select(m => new MinistryDto
|
||||||
{
|
{
|
||||||
Id = m.Id, Name_en = m.Name_en, Name_zh = m.Name_zh,
|
Id = m.Id, Name_en = m.Name_en, Name_zh = m.Name_zh,
|
||||||
|
Description_en = m.Description_en, Description_zh = m.Description_zh,
|
||||||
SortOrder = m.SortOrder, IsActive = m.IsActive,
|
SortOrder = m.SortOrder, IsActive = m.IsActive,
|
||||||
|
DefaultFunctionalClass = m.DefaultFunctionalClass,
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(CreateMinistryRequest r)
|
||||||
|
{
|
||||||
|
var entity = new Ministry
|
||||||
|
{
|
||||||
|
Name_en = r.Name_en, Name_zh = r.Name_zh,
|
||||||
|
Description_en = r.Description_en, Description_zh = r.Description_zh,
|
||||||
|
SortOrder = r.SortOrder, IsActive = true,
|
||||||
|
DefaultFunctionalClass = ROLAC.API.Entities.FunctionalClasses.Normalize(r.DefaultFunctionalClass),
|
||||||
|
};
|
||||||
|
_db.Ministries.Add(entity);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return entity.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int id, UpdateMinistryRequest r)
|
||||||
|
{
|
||||||
|
var m = await _db.Ministries.FindAsync(id)
|
||||||
|
?? throw new KeyNotFoundException($"Ministry {id} not found.");
|
||||||
|
m.Name_en = r.Name_en; m.Name_zh = r.Name_zh;
|
||||||
|
m.Description_en = r.Description_en; m.Description_zh = r.Description_zh;
|
||||||
|
m.IsActive = r.IsActive; m.SortOrder = r.SortOrder;
|
||||||
|
m.DefaultFunctionalClass = ROLAC.API.Entities.FunctionalClasses.Normalize(r.DefaultFunctionalClass);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeactivateAsync(int id)
|
||||||
|
{
|
||||||
|
var m = await _db.Ministries.FindAsync(id)
|
||||||
|
?? throw new KeyNotFoundException($"Ministry {id} not found.");
|
||||||
|
m.IsActive = false;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace ROLAC.API.Services.Notifications;
|
namespace ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
@@ -11,12 +10,12 @@ public sealed class LineMessageChannel : IMessageChannel
|
|||||||
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
|
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
|
||||||
|
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly LineOptions _options;
|
private readonly INotificationSettingsService _settings;
|
||||||
|
|
||||||
public LineMessageChannel(HttpClient http, IOptions<LineOptions> options)
|
public LineMessageChannel(HttpClient http, INotificationSettingsService settings)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_options = options.Value;
|
_settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
|
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
|
||||||
@@ -36,7 +35,8 @@ public sealed class LineMessageChannel : IMessageChannel
|
|||||||
{
|
{
|
||||||
Content = JsonContent.Create(payload),
|
Content = JsonContent.Create(payload),
|
||||||
};
|
};
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ChannelAccessToken);
|
request.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", _settings.GetLine().ChannelAccessToken);
|
||||||
|
|
||||||
using var response = await _http.SendAsync(request, ct);
|
using var response = await _http.SendAsync(request, ct);
|
||||||
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
|
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
using MailKit.Net.Smtp;
|
using MailKit.Net.Smtp;
|
||||||
using MailKit.Security;
|
using MailKit.Security;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
|
|
||||||
namespace ROLAC.API.Services.Notifications;
|
namespace ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
/// <summary>Sends a single email via MailKit using the configured SMTP server.</summary>
|
/// <summary>Sends a single email via MailKit using the current (DB-backed) SMTP settings.</summary>
|
||||||
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
|
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
|
||||||
{
|
{
|
||||||
private readonly SmtpOptions _options;
|
private readonly INotificationSettingsService _settings;
|
||||||
|
|
||||||
public MailKitSmtpDispatcher(IOptions<SmtpOptions> options) => _options = options.Value;
|
public MailKitSmtpDispatcher(INotificationSettingsService settings) => _settings = settings;
|
||||||
|
|
||||||
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
|
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
var options = _settings.GetSmtp();
|
||||||
|
|
||||||
var message = new MimeMessage();
|
var message = new MimeMessage();
|
||||||
message.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress));
|
message.From.Add(new MailboxAddress(options.FromName, options.FromAddress));
|
||||||
message.To.Add(MailboxAddress.Parse(email.ToAddress));
|
message.To.Add(MailboxAddress.Parse(email.ToAddress));
|
||||||
message.Subject = email.Subject;
|
message.Subject = email.Subject;
|
||||||
|
|
||||||
@@ -28,10 +29,10 @@ public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
|
|||||||
message.Body = builder.ToMessageBody();
|
message.Body = builder.ToMessageBody();
|
||||||
|
|
||||||
using var client = new SmtpClient();
|
using var client = new SmtpClient();
|
||||||
var socketOptions = _options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
var socketOptions = options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||||
await client.ConnectAsync(_options.Host, _options.Port, socketOptions, ct);
|
await client.ConnectAsync(options.Host, options.Port, socketOptions, ct);
|
||||||
if (!string.IsNullOrEmpty(_options.User))
|
if (!string.IsNullOrEmpty(options.User))
|
||||||
await client.AuthenticateAsync(_options.User, _options.Password, ct);
|
await client.AuthenticateAsync(options.User, options.Password, ct);
|
||||||
await client.SendAsync(message, ct);
|
await client.SendAsync(message, ct);
|
||||||
await client.DisconnectAsync(true, ct);
|
await client.DisconnectAsync(true, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supplies the current SMTP/Line settings from the <c>NotificationSetting</c> singleton row,
|
||||||
|
/// caching a snapshot in memory so send paths don't hit the DB on every message. Registered as a
|
||||||
|
/// singleton; the Settings UI calls <see cref="Reload"/> after an edit so changes take effect
|
||||||
|
/// without restarting the API. Falls back to the "Smtp"/"Line" appsettings sections if the row
|
||||||
|
/// has not been seeded yet.
|
||||||
|
/// </summary>
|
||||||
|
public interface INotificationSettingsService
|
||||||
|
{
|
||||||
|
SmtpOptions GetSmtp();
|
||||||
|
LineOptions GetLine();
|
||||||
|
void Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NotificationSettingsService : INotificationSettingsService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IOptions<SmtpOptions> _smtpFallback;
|
||||||
|
private readonly IOptions<LineOptions> _lineFallback;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
private SmtpOptions? _smtp;
|
||||||
|
private LineOptions? _line;
|
||||||
|
|
||||||
|
public NotificationSettingsService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<SmtpOptions> smtpFallback,
|
||||||
|
IOptions<LineOptions> lineFallback)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_smtpFallback = smtpFallback;
|
||||||
|
_lineFallback = lineFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SmtpOptions GetSmtp()
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _smtp!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LineOptions GetLine()
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reload()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_smtp = null;
|
||||||
|
_line = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureLoaded()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_smtp is not null && _line is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var row = db.NotificationSettings.AsNoTracking().OrderBy(s => s.Id).FirstOrDefault();
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
// Not seeded yet — use the appsettings values so sends still work.
|
||||||
|
_smtp = _smtpFallback.Value;
|
||||||
|
_line = _lineFallback.Value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_smtp = new SmtpOptions
|
||||||
|
{
|
||||||
|
Host = row.SmtpHost,
|
||||||
|
Port = row.SmtpPort,
|
||||||
|
UseSsl = row.SmtpUseSsl,
|
||||||
|
User = row.SmtpUser,
|
||||||
|
Password = row.SmtpPassword,
|
||||||
|
FromAddress = row.FromAddress,
|
||||||
|
FromName = row.FromName,
|
||||||
|
};
|
||||||
|
_line = new LineOptions
|
||||||
|
{
|
||||||
|
ChannelAccessToken = row.LineChannelAccessToken,
|
||||||
|
ChannelSecret = row.LineChannelSecret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,11 @@ public class OfferingSessionService : IOfferingSessionService
|
|||||||
.Select(grp => new { Id = grp.Key, Count = grp.Count() })
|
.Select(grp => new { Id = grp.Key, Count = grp.Count() })
|
||||||
.ToDictionaryAsync(x => x.Id, x => x.Count);
|
.ToDictionaryAsync(x => x.Id, x => x.Count);
|
||||||
|
|
||||||
|
var dates = rows.Select(r => r.SessionDate).ToList();
|
||||||
|
var attendance = await _db.MealAttendances.AsNoTracking()
|
||||||
|
.Where(a => dates.Contains(a.AttendanceDate))
|
||||||
|
.ToDictionaryAsync(a => a.AttendanceDate, a => a.AdultCount + a.YouthCount + a.KidCount);
|
||||||
|
|
||||||
var items = rows.Select(s => new OfferingSessionListItemDto
|
var items = rows.Select(s => new OfferingSessionListItemDto
|
||||||
{
|
{
|
||||||
Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status,
|
Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status,
|
||||||
@@ -52,6 +57,7 @@ public class OfferingSessionService : IOfferingSessionService
|
|||||||
SystemTotal = s.SystemTotal, Difference = s.Difference,
|
SystemTotal = s.SystemTotal, Difference = s.Difference,
|
||||||
LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0,
|
LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0,
|
||||||
HasProof = s.ProofPdfPath != null,
|
HasProof = s.ProofPdfPath != null,
|
||||||
|
SundayAttendanceCount = attendance.TryGetValue(s.SessionDate, out var att) ? att : (int?)null,
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return new PagedResult<OfferingSessionListItemDto>
|
return new PagedResult<OfferingSessionListItemDto>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Settings;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public class SettingsService : ISettingsService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly INotificationSettingsService _notificationSettings;
|
||||||
|
|
||||||
|
public SettingsService(AppDbContext db, INotificationSettingsService notificationSettings)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_notificationSettings = notificationSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SiteSettingDto> GetSiteAsync()
|
||||||
|
{
|
||||||
|
var s = await GetOrCreateSiteAsync();
|
||||||
|
return new SiteSettingDto
|
||||||
|
{
|
||||||
|
SiteTitle = s.SiteTitle,
|
||||||
|
SiteTitleZh = s.SiteTitleZh,
|
||||||
|
DefaultLanguage = s.DefaultLanguage,
|
||||||
|
TimeZone = s.TimeZone,
|
||||||
|
DateFormat = s.DateFormat,
|
||||||
|
Currency = s.Currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateSiteAsync(UpdateSiteSettingRequest r)
|
||||||
|
{
|
||||||
|
var s = await GetOrCreateSiteAsync();
|
||||||
|
s.SiteTitle = r.SiteTitle;
|
||||||
|
s.SiteTitleZh = r.SiteTitleZh;
|
||||||
|
s.DefaultLanguage = r.DefaultLanguage;
|
||||||
|
s.TimeZone = r.TimeZone;
|
||||||
|
s.DateFormat = r.DateFormat;
|
||||||
|
s.Currency = r.Currency;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NotificationSettingDto> GetNotificationAsync()
|
||||||
|
{
|
||||||
|
var n = await GetOrCreateNotificationAsync();
|
||||||
|
return new NotificationSettingDto
|
||||||
|
{
|
||||||
|
EnableEmail = n.EnableEmail,
|
||||||
|
SmtpHost = n.SmtpHost,
|
||||||
|
SmtpPort = n.SmtpPort,
|
||||||
|
SmtpUseSsl = n.SmtpUseSsl,
|
||||||
|
SmtpUser = n.SmtpUser,
|
||||||
|
FromAddress = n.FromAddress,
|
||||||
|
FromName = n.FromName,
|
||||||
|
HasSmtpPassword = !string.IsNullOrEmpty(n.SmtpPassword),
|
||||||
|
EnableLine = n.EnableLine,
|
||||||
|
HasLineChannelAccessToken = !string.IsNullOrEmpty(n.LineChannelAccessToken),
|
||||||
|
HasLineChannelSecret = !string.IsNullOrEmpty(n.LineChannelSecret),
|
||||||
|
// WebhookUrl is filled by the controller (needs the request host).
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateNotificationAsync(UpdateNotificationSettingRequest r)
|
||||||
|
{
|
||||||
|
var n = await GetOrCreateNotificationAsync();
|
||||||
|
n.EnableEmail = r.EnableEmail;
|
||||||
|
n.SmtpHost = r.SmtpHost;
|
||||||
|
n.SmtpPort = r.SmtpPort;
|
||||||
|
n.SmtpUseSsl = r.SmtpUseSsl;
|
||||||
|
n.SmtpUser = r.SmtpUser;
|
||||||
|
n.FromAddress = r.FromAddress ?? "";
|
||||||
|
n.FromName = r.FromName ?? "";
|
||||||
|
n.EnableLine = r.EnableLine;
|
||||||
|
|
||||||
|
// Secrets are write-only: a blank value means "keep what's stored".
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.SmtpPassword))
|
||||||
|
n.SmtpPassword = r.SmtpPassword;
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.LineChannelAccessToken))
|
||||||
|
n.LineChannelAccessToken = r.LineChannelAccessToken;
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.LineChannelSecret))
|
||||||
|
n.LineChannelSecret = r.LineChannelSecret;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Drop the cached snapshot so the new values are used on the next send — no restart needed.
|
||||||
|
_notificationSettings.Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SiteSetting> GetOrCreateSiteAsync()
|
||||||
|
{
|
||||||
|
var s = await _db.SiteSettings.OrderBy(x => x.Id).FirstOrDefaultAsync();
|
||||||
|
if (s is null)
|
||||||
|
{
|
||||||
|
s = new SiteSetting { SiteTitle = "Church" };
|
||||||
|
_db.SiteSettings.Add(s);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<NotificationSetting> GetOrCreateNotificationAsync()
|
||||||
|
{
|
||||||
|
var n = await _db.NotificationSettings.OrderBy(x => x.Id).FirstOrDefaultAsync();
|
||||||
|
if (n is null)
|
||||||
|
{
|
||||||
|
n = new NotificationSetting();
|
||||||
|
_db.NotificationSettings.Add(n);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { PermissionGuard } from './core/guards/permission.guard';
|
|||||||
import { PermissionModules } from './core/models/permission.model';
|
import { PermissionModules } from './core/models/permission.model';
|
||||||
import { PermissionsPageComponent } from './features/permissions/pages/permissions-page/permissions-page.component';
|
import { PermissionsPageComponent } from './features/permissions/pages/permissions-page/permissions-page.component';
|
||||||
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
|
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
|
||||||
|
import { MinistriesPageComponent } from './features/ministry/pages/ministries-page/ministries-page.component';
|
||||||
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
|
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
|
||||||
import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component';
|
import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component';
|
||||||
import { GivingsPageComponent } from './features/giving/pages/givings-page/givings-page.component';
|
import { GivingsPageComponent } from './features/giving/pages/givings-page/givings-page.component';
|
||||||
@@ -19,16 +20,21 @@ import { FinanceDashboardPageComponent } from './features/finance-dashboard/page
|
|||||||
import { DisbursementPageComponent } from './features/disbursement/pages/disbursement-page/disbursement-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 { 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';
|
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
|
||||||
|
import { Form990ReportPageComponent } from './features/finance-report/pages/form990-report-page/form990-report-page.component';
|
||||||
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
|
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
|
||||||
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
|
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
|
||||||
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
||||||
import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component';
|
import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component';
|
||||||
import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component';
|
import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component';
|
||||||
|
import { AcceptInvitationComponent } from './features/accept-invitation/accept-invitation.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
// Public routes
|
// Public routes
|
||||||
{ path: 'login', component: LoginPage },
|
{ path: 'login', component: LoginPage },
|
||||||
|
|
||||||
|
// Public first-login page — member sets their own password from a secret invitation link.
|
||||||
|
{ path: 'accept-invitation', component: AcceptInvitationComponent },
|
||||||
|
|
||||||
// Public Sunday meal attendance counter — no login required (volunteers on phones).
|
// Public Sunday meal attendance counter — no login required (volunteers on phones).
|
||||||
{ path: 'attendance', component: AttendanceCounterPageComponent },
|
{ path: 'attendance', component: AttendanceCounterPageComponent },
|
||||||
|
|
||||||
@@ -61,6 +67,15 @@ export const routes: Routes = [
|
|||||||
title: 'Member Management', titleZh: '會友管理', section: 'Admin',
|
title: 'Member Management', titleZh: '會友管理', section: 'Admin',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/ministries',
|
||||||
|
component: MinistriesPageComponent,
|
||||||
|
canActivate: [PermissionGuard],
|
||||||
|
data: {
|
||||||
|
permission: { module: PermissionModules.Ministries, action: 'read' },
|
||||||
|
title: 'Ministry Management', titleZh: '事工管理', section: 'Admin',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/users',
|
path: 'admin/users',
|
||||||
component: UsersPageComponent,
|
component: UsersPageComponent,
|
||||||
@@ -192,6 +207,15 @@ export const routes: Routes = [
|
|||||||
title: 'Church Profile', titleZh: '教會資料', section: 'Finance',
|
title: 'Church Profile', titleZh: '教會資料', section: 'Finance',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'finance/form-990-report',
|
||||||
|
component: Form990ReportPageComponent,
|
||||||
|
canActivate: [PermissionGuard],
|
||||||
|
data: {
|
||||||
|
permission: { module: PermissionModules.Form990Report, action: 'read' },
|
||||||
|
title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance',
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const PermissionModules = {
|
|||||||
OfferingSessions: 'OfferingSessions',
|
OfferingSessions: 'OfferingSessions',
|
||||||
Ministries: 'Ministries',
|
Ministries: 'Ministries',
|
||||||
FinanceDashboard: 'FinanceDashboard',
|
FinanceDashboard: 'FinanceDashboard',
|
||||||
|
Form990Report: 'Form990Report',
|
||||||
MonthlyStatements: 'MonthlyStatements',
|
MonthlyStatements: 'MonthlyStatements',
|
||||||
ChurchProfile: 'ChurchProfile',
|
ChurchProfile: 'ChurchProfile',
|
||||||
Disbursements: 'Disbursements',
|
Disbursements: 'Disbursements',
|
||||||
@@ -31,6 +32,7 @@ export const PermissionModules = {
|
|||||||
Permissions: 'Permissions',
|
Permissions: 'Permissions',
|
||||||
SystemLogs: 'SystemLogs',
|
SystemLogs: 'SystemLogs',
|
||||||
AuditLogs: 'AuditLogs',
|
AuditLogs: 'AuditLogs',
|
||||||
|
Settings: 'Settings',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** A required permission, used in route data and the *appHasPermission directive. */
|
/** A required permission, used in route data and the *appHasPermission directive. */
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { LabelModule } from '@progress/kendo-angular-label';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
||||||
|
import { AuthService } from '../../shared/services/auth.service';
|
||||||
|
import {
|
||||||
|
passwordStrengthValidator,
|
||||||
|
passwordMatchValidator,
|
||||||
|
} from '../account/validators/password.validators';
|
||||||
|
|
||||||
|
type Step = 'loading' | 'invalid' | 'form';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-accept-invitation',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule, ReactiveFormsModule,
|
||||||
|
InputsModule, LabelModule, ButtonsModule, IndicatorsModule,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-md rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
|
||||||
|
<h1 class="text-xl font-semibold mb-1">River Of Life Christian Church</h1>
|
||||||
|
|
||||||
|
<!-- Validating the link -->
|
||||||
|
<ng-container *ngIf="step === 'loading'">
|
||||||
|
<div class="text-center py-6">
|
||||||
|
<kendo-loader></kendo-loader>
|
||||||
|
<p class="mt-2 text-gray-600">Checking your invitation…</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Invalid / expired link -->
|
||||||
|
<ng-container *ngIf="step === 'invalid'">
|
||||||
|
<p class="text-base font-medium mb-2">This invitation can't be used</p>
|
||||||
|
<p class="text-gray-600 mb-4">{{ invalidMessage }}</p>
|
||||||
|
<button kendoButton themeColor="primary" (click)="goToLogin()">Go to sign in</button>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Set password form -->
|
||||||
|
<ng-container *ngIf="step === 'form'">
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Welcome<span *ngIf="memberName">, <strong>{{ memberName }}</strong></span>. Set a password to
|
||||||
|
finish creating your account and sign in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form [formGroup]="form" class="k-form k-form-vertical" (ngSubmit)="onSubmit()">
|
||||||
|
<div class="grid grid-cols-1 gap-y-3">
|
||||||
|
|
||||||
|
<kendo-formfield>
|
||||||
|
<kendo-label text="New Password *"></kendo-label>
|
||||||
|
<kendo-textbox formControlName="newPassword" type="password" [clearButton]="false"></kendo-textbox>
|
||||||
|
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['required']">Required.</kendo-formerror>
|
||||||
|
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['passwordStrength']">
|
||||||
|
Must be at least 8 characters with an uppercase letter, a lowercase letter,
|
||||||
|
a digit, and a special character.
|
||||||
|
</kendo-formerror>
|
||||||
|
</kendo-formfield>
|
||||||
|
|
||||||
|
<kendo-formfield>
|
||||||
|
<kendo-label text="Confirm Password *"></kendo-label>
|
||||||
|
<kendo-textbox formControlName="confirmPassword" type="password" [clearButton]="false"></kendo-textbox>
|
||||||
|
<kendo-formerror *ngIf="form.get('confirmPassword')?.errors?.['required']">Required.</kendo-formerror>
|
||||||
|
<kendo-formerror *ngIf="form.errors?.['mismatch'] && form.get('confirmPassword')?.touched">
|
||||||
|
Passwords do not match.
|
||||||
|
</kendo-formerror>
|
||||||
|
</kendo-formfield>
|
||||||
|
|
||||||
|
<p *ngIf="errorMessage" class="k-color-error">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<button kendoButton themeColor="primary" type="submit" [disabled]="form.invalid || submitting">
|
||||||
|
<span *ngIf="submitting">…</span>
|
||||||
|
Set password & sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class AcceptInvitationComponent implements OnInit {
|
||||||
|
step: Step = 'loading';
|
||||||
|
form: FormGroup;
|
||||||
|
submitting = false;
|
||||||
|
memberName: string | null = null;
|
||||||
|
invalidMessage = 'This invitation link is invalid or has already been used.';
|
||||||
|
errorMessage = '';
|
||||||
|
|
||||||
|
private token = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fb: FormBuilder,
|
||||||
|
private auth: AuthService,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
) {
|
||||||
|
this.form = this.fb.group(
|
||||||
|
{
|
||||||
|
newPassword: ['', [Validators.required, passwordStrengthValidator()]],
|
||||||
|
confirmPassword: ['', [Validators.required]],
|
||||||
|
},
|
||||||
|
{ validators: passwordMatchValidator() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.token = this.route.snapshot.queryParamMap.get('token') ?? '';
|
||||||
|
if (!this.token) {
|
||||||
|
this.step = 'invalid';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.auth.validateInvitation(this.token).subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
if (result.valid) {
|
||||||
|
this.memberName = result.memberName ?? null;
|
||||||
|
this.step = 'form';
|
||||||
|
} else {
|
||||||
|
this.invalidMessage = result.expired
|
||||||
|
? 'This invitation link has expired. Please ask for a new one.'
|
||||||
|
: 'This invitation link is invalid or has already been used.';
|
||||||
|
this.step = 'invalid';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => { this.step = 'invalid'; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(): void {
|
||||||
|
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
|
||||||
|
this.submitting = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
|
||||||
|
this.auth.acceptInvitation(this.token, this.form.value.newPassword).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.router.navigate(['/user-portal/dashboard']);
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.errorMessage = err.error?.message ?? 'Could not set your password. The link may have expired.';
|
||||||
|
this.submitting = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goToLogin(): void {
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,8 @@ export interface CheckDetailDto extends CheckListItemDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChurchProfileDto {
|
export interface ChurchProfileDto {
|
||||||
id: number; name: string; address: string | null; city: string | null;
|
id: number; name: string; nameZh: string | null; phone: string | null;
|
||||||
|
email: string | null; website: string | null; address: string | null; city: string | null;
|
||||||
state: string | null; zipCode: string | null; bankName: string | null;
|
state: string | null; zipCode: string | null; bankName: string | null;
|
||||||
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
|
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-2
@@ -1,10 +1,30 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<div *ngIf="model" class="max-w-3xl">
|
<kendo-tabstrip>
|
||||||
|
<!-- ── Tab 1: Church Info (existing ChurchProfile permission) ──────────── -->
|
||||||
|
<kendo-tabstrip-tab title="Church Info / 教會資料" [selected]="true">
|
||||||
|
<ng-template kendoTabContent>
|
||||||
|
<div *ngIf="model" class="max-w-3xl pt-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">
|
<label class="flex flex-col gap-1">
|
||||||
Church Name / 教會名稱
|
Church Name / 教會名稱
|
||||||
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Church Name (ZH) / 教會名稱(中)
|
||||||
|
<kendo-textbox [(ngModel)]="model.nameZh"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Phone / 電話
|
||||||
|
<kendo-textbox [(ngModel)]="model.phone"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Email / 電子郵件
|
||||||
|
<kendo-textbox [(ngModel)]="model.email"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
|
Website / 網站
|
||||||
|
<kendo-textbox [(ngModel)]="model.website" placeholder="https://"></kendo-textbox>
|
||||||
|
</label>
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
Address / 地址
|
Address / 地址
|
||||||
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
|
||||||
@@ -46,4 +66,21 @@
|
|||||||
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-tabstrip-tab>
|
||||||
|
|
||||||
|
<!-- ── Tab 2: Site Settings (Settings permission) ─────────────────────── -->
|
||||||
|
<kendo-tabstrip-tab title="Site Settings / 網站設定" *appHasPermission="settingsPermission">
|
||||||
|
<ng-template kendoTabContent>
|
||||||
|
<app-site-settings-tab></app-site-settings-tab>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-tabstrip-tab>
|
||||||
|
|
||||||
|
<!-- ── Tab 3: Notification Settings (Settings permission) ─────────────── -->
|
||||||
|
<kendo-tabstrip-tab title="Notifications / 通知設定" *appHasPermission="settingsPermission">
|
||||||
|
<ng-template kendoTabContent>
|
||||||
|
<app-notification-settings-tab></app-notification-settings-tab>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-tabstrip-tab>
|
||||||
|
</kendo-tabstrip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-1
@@ -3,13 +3,21 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { LayoutModule } from '@progress/kendo-angular-layout';
|
||||||
import { DisbursementApiService } from '../../services/disbursement-api.service';
|
import { DisbursementApiService } from '../../services/disbursement-api.service';
|
||||||
import { ChurchProfileDto } from '../../models/disbursement.model';
|
import { ChurchProfileDto } from '../../models/disbursement.model';
|
||||||
|
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
|
||||||
|
import { PermissionModules } from '../../../../core/models/permission.model';
|
||||||
|
import { SiteSettingsTabComponent } from '../../../settings/components/site-settings-tab/site-settings-tab.component';
|
||||||
|
import { NotificationSettingsTabComponent } from '../../../settings/components/notification-settings-tab/notification-settings-tab.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-church-profile-page',
|
selector: 'app-church-profile-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
|
imports: [
|
||||||
|
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule,
|
||||||
|
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
|
||||||
|
],
|
||||||
templateUrl: './church-profile-page.component.html',
|
templateUrl: './church-profile-page.component.html',
|
||||||
})
|
})
|
||||||
export class ChurchProfilePageComponent implements OnInit {
|
export class ChurchProfilePageComponent implements OnInit {
|
||||||
@@ -17,6 +25,9 @@ export class ChurchProfilePageComponent implements OnInit {
|
|||||||
saving = false;
|
saving = false;
|
||||||
savedMsg = '';
|
savedMsg = '';
|
||||||
|
|
||||||
|
/** Settings module gates the Site / Notification tabs. */
|
||||||
|
readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const };
|
||||||
|
|
||||||
constructor(private api: DisbursementApiService) {}
|
constructor(private api: DisbursementApiService) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
|||||||
+92
-60
@@ -1,5 +1,8 @@
|
|||||||
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="showReceiptPanel ? 1200 : 760" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
<!-- Two columns on desktop: form on the left, receipt preview on the right. Stacks on mobile. -->
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row">
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
|
||||||
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
|
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
|
||||||
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
||||||
@@ -7,77 +10,80 @@
|
|||||||
<span>連續登打 / Continuous Entry</span>
|
<span>連續登打 / Continuous Entry</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">Description
|
||||||
|
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
|
||||||
|
</label>
|
||||||
<!-- Member picker (finance creating on behalf of a member) -->
|
<!-- Member picker (finance creating on behalf of a member) -->
|
||||||
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
|
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
|
||||||
<kendo-dropdownlist
|
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id" [valuePrimitive]="true"
|
||||||
[data]="memberResults"
|
[filterable]="true" (filterChange)="onMemberFilter($event)" [(ngModel)]="form.memberId"
|
||||||
textField="displayName"
|
|
||||||
valueField="id"
|
|
||||||
[valuePrimitive]="true"
|
|
||||||
[filterable]="true"
|
|
||||||
(filterChange)="onMemberFilter($event)"
|
|
||||||
[(ngModel)]="form.memberId"
|
|
||||||
placeholder="Search member by name">
|
placeholder="Search member by name">
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Ministry -->
|
<!-- Ministry -->
|
||||||
<label class="flex flex-col gap-1">Ministry
|
<label class="flex flex-col gap-1">Ministry
|
||||||
<kendo-dropdownlist
|
<kendo-dropdownlist [data]="ministries" textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
[data]="ministries"
|
[(ngModel)]="form.ministryId" [defaultItem]="{ id: null, label: '-- Select ministry --/請選擇事工' }">
|
||||||
textField="label"
|
|
||||||
valueField="id"
|
|
||||||
[valuePrimitive]="true"
|
|
||||||
[(ngModel)]="form.ministryId"
|
|
||||||
[defaultItem]="{ id: null, label: '-- Select ministry --/請選擇事工' }">
|
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Category Group -->
|
|
||||||
<label class="flex flex-col gap-1">Category Group
|
|
||||||
<kendo-dropdownlist
|
|
||||||
[data]="groups"
|
|
||||||
textField="label"
|
|
||||||
valueField="id"
|
|
||||||
[valuePrimitive]="true"
|
|
||||||
[(ngModel)]="form.categoryGroupId"
|
|
||||||
(valueChange)="onGroupChange($event)"
|
|
||||||
[defaultItem]="{ id: null, label: '-- Select group --/請選擇大類' }">
|
|
||||||
</kendo-dropdownlist>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Sub-Category -->
|
|
||||||
<label class="flex flex-col gap-1">Sub-Category
|
|
||||||
<kendo-dropdownlist
|
|
||||||
[data]="subs"
|
|
||||||
textField="label"
|
|
||||||
valueField="id"
|
|
||||||
[valuePrimitive]="true"
|
|
||||||
[(ngModel)]="form.subCategoryId"
|
|
||||||
[defaultItem]="{ id: null, label: '-- Select sub-category --/請選擇子項' }"
|
|
||||||
[disabled]="!form.categoryGroupId">
|
|
||||||
</kendo-dropdownlist>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Amount -->
|
|
||||||
<label class="flex flex-col gap-1">Amount
|
|
||||||
<kendo-numerictextbox
|
|
||||||
[(ngModel)]="form.amount"
|
|
||||||
[min]="0"
|
|
||||||
[format]="'c2'">
|
|
||||||
</kendo-numerictextbox>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Expense Date -->
|
<!-- Expense Date -->
|
||||||
<label class="flex flex-col gap-1">Expense Date
|
<label class="flex flex-col gap-1">Expense Date
|
||||||
<kendo-datepicker [(ngModel)]="form.expenseDate"></kendo-datepicker>
|
<kendo-datepicker [(ngModel)]="form.expenseDate"></kendo-datepicker>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Category lines: one invoice can span several categories -->
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">Description
|
<div class="md:col-span-2 flex flex-col gap-2">
|
||||||
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold">明細 / Line Items</span>
|
||||||
|
<span class="text-sm text-gray-600">合計 / Total: {{ totalAmount | currency }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngFor="let line of lines; let i = index" class="flex flex-col gap-3 rounded border border-gray-200 p-3">
|
||||||
|
<!-- Line header: number + remove -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-gray-600">明細 {{ i + 1 }} / Item {{ i + 1 }}</span>
|
||||||
|
<button kendoButton fillMode="flat" themeColor="error" size="small" [disabled]="lines.length === 1"
|
||||||
|
(click)="removeLine(i)" title="Remove line / 刪除此列">✕ 刪除</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 1: Category Group + Sub-Category -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<label class="flex flex-col gap-1">Category Group
|
||||||
|
<kendo-dropdownlist [data]="groups" textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="line.categoryGroupId" (valueChange)="onLineGroupChange(line, $event)"
|
||||||
|
[defaultItem]="{ id: null, label: '-- Select group --/請選擇大類' }">
|
||||||
|
</kendo-dropdownlist>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">Sub-Category
|
||||||
|
<kendo-dropdownlist [data]="line.subs" textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="line.subCategoryId" [defaultItem]="{ id: null, label: '-- Select sub-category --/請選擇子項' }"
|
||||||
|
[disabled]="!line.categoryGroupId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Description (optional) + Amount -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<label class="flex flex-col gap-1">Description / 說明 <span class="text-gray-400">(Optional)</span>
|
||||||
|
<kendo-textbox [(ngModel)]="line.description" placeholder="e.g. 點心、文具…"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">Amount
|
||||||
|
<kendo-numerictextbox [(ngModel)]="line.amount" [min]="0" [format]="'c2'"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button kendoButton fillMode="outline" (click)="addLine()">+ 新增一列 / Add Line</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Vendor mode: vendor name + check number -->
|
<!-- Vendor mode: vendor name + check number -->
|
||||||
<ng-container *ngIf="mode === 'vendor'">
|
<ng-container *ngIf="mode === 'vendor'">
|
||||||
<label class="flex flex-col gap-1">Vendor Name
|
<label class="flex flex-col gap-1">Vendor Name
|
||||||
@@ -96,17 +102,43 @@
|
|||||||
from bubbling up to the host, where it would collide with this component's
|
from bubbling up to the host, where it would collide with this component's
|
||||||
@Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997.
|
@Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997.
|
||||||
-->
|
-->
|
||||||
<input
|
<input #receiptInput type="file" accept="image/*,application/pdf" (change)="onFileSelected($event)"
|
||||||
#receiptInput
|
|
||||||
type="file"
|
|
||||||
accept="image/*,application/pdf"
|
|
||||||
(change)="onFileSelected($event)"
|
|
||||||
(cancel)="$event.stopPropagation()"
|
(cancel)="$event.stopPropagation()"
|
||||||
class="block w-full text-sm text-gray-700 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-gray-100 hover:file:bg-gray-200" />
|
class="block w-full text-sm text-gray-700 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-gray-100 hover:file:bg-gray-200" />
|
||||||
</label>
|
</label>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- /left form column -->
|
||||||
|
|
||||||
|
<!-- Right: receipt preview (shown once a file is selected or an existing receipt is loaded) -->
|
||||||
|
<div *ngIf="showReceiptPanel" class="md:w-[34rem] md:shrink-0 md:border-l md:pl-4 flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold">收據預覽 / Receipt</span>
|
||||||
|
<!-- Zoom controls (image only; PDF uses the browser viewer's own zoom) -->
|
||||||
|
<div *ngIf="receiptImageUrl" class="flex items-center gap-1">
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="zoomOut()"
|
||||||
|
[disabled]="receiptZoom <= minZoom" title="縮小 / Zoom out">−</button>
|
||||||
|
<span class="w-12 text-center text-sm tabular-nums">{{ receiptZoom * 100 | number:'1.0-0' }}%</span>
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="zoomIn()"
|
||||||
|
[disabled]="receiptZoom >= maxZoom" title="放大 / Zoom in">+</button>
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="resetZoom()" title="重設 / Reset">⟲</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable so a zoomed-in image can be panned for comparison -->
|
||||||
|
<div *ngIf="receiptImageUrl" class="overflow-auto rounded border border-gray-200 bg-gray-50"
|
||||||
|
style="max-height: 72vh;">
|
||||||
|
<img [src]="receiptImageUrl" alt="Receipt preview" [style.width.%]="receiptZoom * 100"
|
||||||
|
class="block max-w-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<iframe *ngIf="receiptPdfUrl" [src]="receiptPdfUrl" title="Receipt PDF"
|
||||||
|
class="w-full rounded border border-gray-200" style="height: 72vh;"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /two-column body -->
|
||||||
|
|
||||||
<kendo-dialog-actions>
|
<kendo-dialog-actions>
|
||||||
<button kendoButton (click)="cancel.emit()">Cancel</button>
|
<button kendoButton (click)="cancel.emit()">Cancel</button>
|
||||||
|
|||||||
+124
-31
@@ -1,6 +1,7 @@
|
|||||||
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
|
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
@@ -8,11 +9,12 @@ import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
|||||||
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||||
import { MinistryApiService } from '../../services/ministry-api.service';
|
import { MinistryApiService } from '../../services/ministry-api.service';
|
||||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||||
|
import { ExpenseApiService } from '../../services/expense-api.service';
|
||||||
import { MemberApiService } from '../../../members/services/member-api.service';
|
import { MemberApiService } from '../../../members/services/member-api.service';
|
||||||
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||||
import {
|
import {
|
||||||
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
|
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
|
||||||
ExpenseListItemDto,
|
ExpenseDto, FunctionalClass,
|
||||||
} from '../../models/expense.model';
|
} from '../../models/expense.model';
|
||||||
|
|
||||||
export interface ExpenseFormResult {
|
export interface ExpenseFormResult {
|
||||||
@@ -25,18 +27,30 @@ export interface ExpenseFormResult {
|
|||||||
/** Flattened member item with a single displayName field for the dropdown. */
|
/** Flattened member item with a single displayName field for the dropdown. */
|
||||||
interface MemberOption { id: number; displayName: string; }
|
interface MemberOption { id: number; displayName: string; }
|
||||||
|
|
||||||
|
/** One editable category line. `subs` holds the sub-category list for this row's chosen group. */
|
||||||
|
interface ExpenseLineForm {
|
||||||
|
categoryGroupId: number | null;
|
||||||
|
subCategoryId: number | null;
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
/** Functional class is no longer exposed in the form (too complex for volunteers); it stays
|
||||||
|
* null = inherit the ministry default. Kept here so existing overrides survive an edit. */
|
||||||
|
functionalClass: FunctionalClass | null;
|
||||||
|
subs: ExpenseSubCategoryDto[];
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-expense-form-dialog',
|
selector: 'app-expense-form-dialog',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule, DropDownsModule, DateInputsModule],
|
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule, DropDownsModule, DateInputsModule],
|
||||||
templateUrl: './expense-form-dialog.component.html',
|
templateUrl: './expense-form-dialog.component.html',
|
||||||
})
|
})
|
||||||
export class ExpenseFormDialogComponent implements OnInit {
|
export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
||||||
@Input() mode: 'vendor' | 'reimbursement' = 'reimbursement';
|
@Input() mode: 'vendor' | 'reimbursement' = 'reimbursement';
|
||||||
@Input() allowMemberPick = false;
|
@Input() allowMemberPick = false;
|
||||||
@Input() title = 'New Expense';
|
@Input() title = 'New Expense';
|
||||||
/** When set, the dialog prefills from this row for editing instead of starting blank. */
|
/** When set, the dialog prefills from this expense (with its lines) for editing. */
|
||||||
@Input() expense: ExpenseListItemDto | null = null;
|
@Input() expense: ExpenseDto | null = null;
|
||||||
@Output() save = new EventEmitter<ExpenseFormResult>();
|
@Output() save = new EventEmitter<ExpenseFormResult>();
|
||||||
@Output() cancel = new EventEmitter<void>();
|
@Output() cancel = new EventEmitter<void>();
|
||||||
|
|
||||||
@@ -45,7 +59,6 @@ export class ExpenseFormDialogComponent implements OnInit {
|
|||||||
|
|
||||||
ministries: MinistryDto[] = [];
|
ministries: MinistryDto[] = [];
|
||||||
groups: ExpenseCategoryGroupDto[] = [];
|
groups: ExpenseCategoryGroupDto[] = [];
|
||||||
subs: ExpenseSubCategoryDto[] = [];
|
|
||||||
|
|
||||||
memberResults: MemberOption[] = [];
|
memberResults: MemberOption[] = [];
|
||||||
|
|
||||||
@@ -59,54 +72,108 @@ export class ExpenseFormDialogComponent implements OnInit {
|
|||||||
|
|
||||||
form = {
|
form = {
|
||||||
ministryId: null as number | null,
|
ministryId: null as number | null,
|
||||||
categoryGroupId: null as number | null,
|
|
||||||
subCategoryId: null as number | null,
|
|
||||||
amount: 0,
|
|
||||||
description: '',
|
description: '',
|
||||||
vendorName: '',
|
vendorName: '',
|
||||||
checkNumber: '',
|
checkNumber: '',
|
||||||
memberId: null as number | null,
|
memberId: null as number | null,
|
||||||
expenseDate: new Date(),
|
expenseDate: new Date(),
|
||||||
};
|
};
|
||||||
|
/** At least one line always; "+ Add line" appends, each line is independently removable down to one. */
|
||||||
|
lines: ExpenseLineForm[] = [this.emptyLine()];
|
||||||
receipt: File | null = null;
|
receipt: File | null = null;
|
||||||
|
|
||||||
|
// ── Receipt preview (right panel) ────────────────────────────────────────
|
||||||
|
/** Blob URL for an image receipt, bound directly to <img [src]>. */
|
||||||
|
receiptImageUrl: string | null = null;
|
||||||
|
/** Sanitized blob URL for a PDF receipt, bound to <iframe [src]>. */
|
||||||
|
receiptPdfUrl: SafeResourceUrl | null = null;
|
||||||
|
/** Raw object URL kept so it can be revoked. */
|
||||||
|
private receiptObjectUrl: string | null = null;
|
||||||
|
|
||||||
|
/** Image zoom factor (1 = fit panel width); lets volunteers blow up a receipt to compare. */
|
||||||
|
receiptZoom = 1;
|
||||||
|
readonly minZoom = 0.5;
|
||||||
|
readonly maxZoom = 5;
|
||||||
|
|
||||||
|
get showReceiptPanel(): boolean { return !!(this.receiptImageUrl || this.receiptPdfUrl); }
|
||||||
|
|
||||||
|
zoomIn(): void { this.receiptZoom = Math.min(this.maxZoom, +(this.receiptZoom + 0.25).toFixed(2)); }
|
||||||
|
zoomOut(): void { this.receiptZoom = Math.max(this.minZoom, +(this.receiptZoom - 0.25).toFixed(2)); }
|
||||||
|
resetZoom(): void { this.receiptZoom = 1; }
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private ministryApi: MinistryApiService,
|
private ministryApi: MinistryApiService,
|
||||||
private catApi: ExpenseCategoryApiService,
|
private catApi: ExpenseCategoryApiService,
|
||||||
private memberApi: MemberApiService,
|
private memberApi: MemberApiService,
|
||||||
|
private expenseApi: ExpenseApiService,
|
||||||
|
private sanitizer: DomSanitizer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
||||||
this.catApi.getAll(false).subscribe(groups => {
|
this.catApi.getAll(false).subscribe(groups => {
|
||||||
this.groups = groups;
|
this.groups = groups;
|
||||||
// Populate the sub-category list for the prefilled group so its value displays on edit.
|
// Populate each line's sub-category list once the catalog is loaded (edit mode).
|
||||||
if (this.expense) {
|
if (this.expense) this.hydrateLineSubs();
|
||||||
this.subs = this.groups.find(group => group.id === this.expense!.categoryGroupId)?.subCategories ?? [];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (this.expense) this.prefill(this.expense);
|
if (this.expense) {
|
||||||
|
this.prefill(this.expense);
|
||||||
|
// Edit mode: load the existing receipt into the preview panel.
|
||||||
|
if (this.expense.hasReceipt) {
|
||||||
|
this.expenseApi.downloadReceipt(this.expense.id)
|
||||||
|
.subscribe(blob => this.setPreview(blob, blob.type));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private prefill(expense: ExpenseListItemDto): void {
|
ngOnDestroy(): void { this.clearPreview(); }
|
||||||
|
|
||||||
|
private emptyLine(): ExpenseLineForm {
|
||||||
|
return { categoryGroupId: null, subCategoryId: null, amount: 0, description: '', functionalClass: null, subs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private prefill(expense: ExpenseDto): void {
|
||||||
// expenseDate is a "yyyy-MM-dd" string; build a local Date to avoid a timezone day-shift.
|
// 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);
|
const [year, month, day] = expense.expenseDate.split('-').map(Number);
|
||||||
this.form = {
|
this.form = {
|
||||||
ministryId: expense.ministryId,
|
ministryId: expense.ministryId,
|
||||||
categoryGroupId: expense.categoryGroupId,
|
|
||||||
subCategoryId: expense.subCategoryId,
|
|
||||||
amount: expense.amount,
|
|
||||||
description: expense.description,
|
description: expense.description,
|
||||||
vendorName: expense.vendorName ?? '',
|
vendorName: expense.vendorName ?? '',
|
||||||
checkNumber: expense.checkNumber ?? '',
|
checkNumber: expense.checkNumber ?? '',
|
||||||
memberId: expense.memberId,
|
memberId: expense.memberId,
|
||||||
expenseDate: new Date(year, month - 1, day),
|
expenseDate: new Date(year, month - 1, day),
|
||||||
};
|
};
|
||||||
|
this.lines = (expense.lines ?? []).map(l => ({
|
||||||
|
categoryGroupId: l.categoryGroupId,
|
||||||
|
subCategoryId: l.subCategoryId,
|
||||||
|
amount: l.amount,
|
||||||
|
description: l.description ?? '',
|
||||||
|
functionalClass: l.functionalClass,
|
||||||
|
subs: [],
|
||||||
|
}));
|
||||||
|
if (this.lines.length === 0) this.lines = [this.emptyLine()];
|
||||||
}
|
}
|
||||||
|
|
||||||
onGroupChange(groupId: number | null): void {
|
/** Fill each line's sub-category list from its chosen group (used after the catalog loads on edit). */
|
||||||
this.form.subCategoryId = null;
|
private hydrateLineSubs(): void {
|
||||||
this.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
|
for (const line of this.lines) {
|
||||||
|
line.subs = this.groups.find(g => g.id === line.categoryGroupId)?.subCategories ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLineGroupChange(line: ExpenseLineForm, groupId: number | null): void {
|
||||||
|
line.subCategoryId = null;
|
||||||
|
line.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
addLine(): void { this.lines.push(this.emptyLine()); }
|
||||||
|
|
||||||
|
removeLine(index: number): void {
|
||||||
|
if (this.lines.length > 1) this.lines.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalAmount(): number {
|
||||||
|
return this.lines.reduce((sum, l) => sum + (l.amount || 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMemberFilter(term: string): void {
|
onMemberFilter(term: string): void {
|
||||||
@@ -122,12 +189,34 @@ export class ExpenseFormDialogComponent implements OnInit {
|
|||||||
|
|
||||||
onFileSelected(event: Event): void {
|
onFileSelected(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
this.receipt = input.files?.[0] ?? null;
|
const file = input.files?.[0] ?? null;
|
||||||
|
this.receipt = file;
|
||||||
|
if (file) this.setPreview(file, file.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show a newly-selected file or a fetched existing receipt in the right-hand preview panel. */
|
||||||
|
private setPreview(blob: Blob, contentType: string): void {
|
||||||
|
this.clearPreview();
|
||||||
|
this.receiptZoom = 1;
|
||||||
|
this.receiptObjectUrl = URL.createObjectURL(blob);
|
||||||
|
if (contentType.startsWith('image/')) {
|
||||||
|
this.receiptImageUrl = this.receiptObjectUrl;
|
||||||
|
} else {
|
||||||
|
this.receiptPdfUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.receiptObjectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPreview(): void {
|
||||||
|
if (this.receiptObjectUrl) { URL.revokeObjectURL(this.receiptObjectUrl); this.receiptObjectUrl = null; }
|
||||||
|
this.receiptImageUrl = null;
|
||||||
|
this.receiptPdfUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isValid(): boolean {
|
get isValid(): boolean {
|
||||||
return !!this.form.ministryId && !!this.form.categoryGroupId && !!this.form.subCategoryId
|
return !!this.form.ministryId
|
||||||
&& this.form.amount > 0 && this.form.description.trim().length > 0;
|
&& this.form.description.trim().length > 0
|
||||||
|
&& this.lines.length > 0
|
||||||
|
&& this.lines.every(l => !!l.categoryGroupId && !!l.subCategoryId && l.amount > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitSave(): void {
|
emitSave(): void {
|
||||||
@@ -137,9 +226,13 @@ export class ExpenseFormDialogComponent implements OnInit {
|
|||||||
const request: CreateExpenseRequest = {
|
const request: CreateExpenseRequest = {
|
||||||
type: (this.mode === 'vendor' ? 'VendorPayment' : 'StaffReimbursement') as ExpenseType,
|
type: (this.mode === 'vendor' ? 'VendorPayment' : 'StaffReimbursement') as ExpenseType,
|
||||||
ministryId: this.form.ministryId!,
|
ministryId: this.form.ministryId!,
|
||||||
categoryGroupId: this.form.categoryGroupId!,
|
lines: this.lines.map(l => ({
|
||||||
subCategoryId: this.form.subCategoryId!,
|
categoryGroupId: l.categoryGroupId!,
|
||||||
amount: this.form.amount,
|
subCategoryId: l.subCategoryId!,
|
||||||
|
amount: l.amount,
|
||||||
|
functionalClass: l.functionalClass,
|
||||||
|
description: l.description.trim() || null,
|
||||||
|
})),
|
||||||
description: this.form.description.trim(),
|
description: this.form.description.trim(),
|
||||||
vendorName: this.mode === 'vendor' ? (this.form.vendorName || null) : null,
|
vendorName: this.mode === 'vendor' ? (this.form.vendorName || null) : null,
|
||||||
memberId: this.allowMemberPick ? this.form.memberId : null,
|
memberId: this.allowMemberPick ? this.form.memberId : null,
|
||||||
@@ -154,14 +247,14 @@ export class ExpenseFormDialogComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear only the per-entry fields, keeping Member, Ministry, Category Group,
|
* Clear only the per-entry fields, keeping Member, Ministry and Expense Date so the
|
||||||
* Sub-Category and Expense Date (plus the loaded sub-category list) so the
|
* user can immediately log the next reimbursement. Lines reset to a single blank row.
|
||||||
* user can immediately log the next reimbursement.
|
|
||||||
*/
|
*/
|
||||||
private resetForNext(): void {
|
private resetForNext(): void {
|
||||||
this.form.amount = 0;
|
this.lines = [this.emptyLine()];
|
||||||
this.form.description = '';
|
this.form.description = '';
|
||||||
this.receipt = null;
|
this.receipt = null;
|
||||||
|
this.clearPreview();
|
||||||
if (this.receiptInput) this.receiptInput.nativeElement.value = '';
|
if (this.receiptInput) this.receiptInput.nativeElement.value = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
<kendo-dialog title="Review Expense / 審核支出" (close)="cancel.emit()"
|
||||||
|
[width]="showReceiptPanel ? 1100 : 620" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||||
|
|
||||||
|
<div *ngIf="loading" class="p-6 text-center text-gray-500">Loading… / 載入中…</div>
|
||||||
|
|
||||||
|
<div *ngIf="!loading && expense" class="flex flex-col gap-4 md:flex-row">
|
||||||
|
|
||||||
|
<!-- Left: read-only expense detail -->
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col gap-3">
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||||
|
<div class="text-gray-500">Date / 日期</div><div>{{ expense.expenseDate }}</div>
|
||||||
|
<div class="text-gray-500">Ministry / 事工</div><div>{{ expense.ministryName }}</div>
|
||||||
|
<div class="text-gray-500">Payee / 收款人</div><div>{{ expense.vendorName || expense.memberName || '—' }}</div>
|
||||||
|
<div class="text-gray-500">Description / 說明</div><div>{{ expense.description }}</div>
|
||||||
|
<div class="text-gray-500">Status / 狀態</div><div>{{ expense.status }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line items -->
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="font-semibold text-sm">明細 / Line Items</div>
|
||||||
|
<table class="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-gray-500 border-b">
|
||||||
|
<th class="py-1">Category / 類別</th>
|
||||||
|
<th class="py-1">Description / 說明</th>
|
||||||
|
<th class="py-1 text-right">Amount / 金額</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let line of expense.lines" class="border-b border-gray-100">
|
||||||
|
<td class="py-1">{{ line.categoryGroupName }}<span *ngIf="line.subCategoryName"> / {{ line.subCategoryName }}</span></td>
|
||||||
|
<td class="py-1 text-gray-600">{{ line.description || '—' }}</td>
|
||||||
|
<td class="py-1 text-right tabular-nums">{{ line.amount | currency }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="font-semibold">
|
||||||
|
<td class="py-1" colspan="2">Total / 合計</td>
|
||||||
|
<td class="py-1 text-right tabular-nums">{{ expense.amount | currency }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reject reason capture (shown after clicking Reject) -->
|
||||||
|
<div *ngIf="rejecting" class="flex flex-col gap-2 rounded border border-red-200 bg-red-50 p-3">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">Reject Reason / 拒絕原因
|
||||||
|
<kendo-dropdownlist [data]="rejectReasons" textField="label" valueField="value" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="rejectReason" [defaultItem]="{ value: null, label: '-- Select reason --/請選擇原因' }">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
<label *ngIf="isOtherReason" class="flex flex-col gap-1 text-sm">Detail / 說明
|
||||||
|
<kendo-textbox [(ngModel)]="rejectOther" placeholder="Please enter the reason / 請輸入原因"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: receipt preview -->
|
||||||
|
<div class="md:w-[30rem] md:shrink-0 md:border-l md:pl-4 flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold">收據預覽 / Receipt</span>
|
||||||
|
<div *ngIf="receiptImageUrl" class="flex items-center gap-1">
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="zoomOut()"
|
||||||
|
[disabled]="receiptZoom <= minZoom" title="縮小 / Zoom out">−</button>
|
||||||
|
<span class="w-12 text-center text-sm tabular-nums">{{ receiptZoom * 100 | number:'1.0-0' }}%</span>
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="zoomIn()"
|
||||||
|
[disabled]="receiptZoom >= maxZoom" title="放大 / Zoom in">+</button>
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="resetZoom()" title="重設 / Reset">⟲</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="receiptImageUrl" class="overflow-auto rounded border border-gray-200 bg-gray-50" style="max-height: 72vh;">
|
||||||
|
<img [src]="receiptImageUrl" alt="Receipt preview" [style.width.%]="receiptZoom * 100" class="block max-w-none" />
|
||||||
|
</div>
|
||||||
|
<iframe *ngIf="receiptPdfUrl" [src]="receiptPdfUrl" title="Receipt PDF"
|
||||||
|
class="w-full rounded border border-gray-200" style="height: 72vh;"></iframe>
|
||||||
|
|
||||||
|
<div *ngIf="!showReceiptPanel" class="rounded border border-dashed border-gray-300 p-6 text-center text-gray-400 text-sm">
|
||||||
|
No receipt attached / 無收據
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<!-- Decision row -->
|
||||||
|
<ng-container *ngIf="!rejecting">
|
||||||
|
<button kendoButton (click)="cancel.emit()">Cancel / 取消</button>
|
||||||
|
<button kendoButton themeColor="error" fillMode="flat" (click)="startReject()">Reject / 拒絕</button>
|
||||||
|
<button kendoButton themeColor="success" (click)="confirmApprove()">Approve / 核准</button>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Reject confirmation row -->
|
||||||
|
<ng-container *ngIf="rejecting">
|
||||||
|
<button kendoButton (click)="cancelReject()">Back / 返回</button>
|
||||||
|
<button kendoButton themeColor="error" [disabled]="!canConfirmReject" (click)="confirmReject()">Confirm Reject / 確認拒絕</button>
|
||||||
|
</ng-container>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { ExpenseApiService } from '../../services/expense-api.service';
|
||||||
|
import { ExpenseDto } from '../../models/expense.model';
|
||||||
|
import { EXPENSE_REJECT_REASON_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval review dialog: shows the full expense detail and a receipt preview side by side,
|
||||||
|
* then lets a reviewer Approve or Reject (with a templated or free-text reason). The parent
|
||||||
|
* performs the actual api.approve / api.reject call from the emitted events.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-expense-review-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, ButtonsModule, DialogsModule, DropDownsModule, InputsModule],
|
||||||
|
templateUrl: './expense-review-dialog.component.html',
|
||||||
|
})
|
||||||
|
export class ExpenseReviewDialogComponent implements OnInit, OnDestroy {
|
||||||
|
/** Expense to review; the full detail (with lines) is fetched on open. */
|
||||||
|
@Input() expenseId!: number;
|
||||||
|
@Output() approve = new EventEmitter<void>();
|
||||||
|
@Output() reject = new EventEmitter<string>(); // emits the composed reviewNotes
|
||||||
|
@Output() cancel = new EventEmitter<void>();
|
||||||
|
|
||||||
|
expense: ExpenseDto | null = null;
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
readonly rejectReasons = EXPENSE_REJECT_REASON_OPTIONS;
|
||||||
|
/** false = the Approve/Reject choice; true = the reject reason is being collected. */
|
||||||
|
rejecting = false;
|
||||||
|
rejectReason: string | null = null;
|
||||||
|
rejectOther = '';
|
||||||
|
|
||||||
|
// ── Receipt preview (mirrors expense-form-dialog) ───────────────────────
|
||||||
|
receiptImageUrl: string | null = null;
|
||||||
|
receiptPdfUrl: SafeResourceUrl | null = null;
|
||||||
|
private receiptObjectUrl: string | null = null;
|
||||||
|
receiptZoom = 1;
|
||||||
|
readonly minZoom = 0.5;
|
||||||
|
readonly maxZoom = 5;
|
||||||
|
|
||||||
|
get showReceiptPanel(): boolean { return !!(this.receiptImageUrl || this.receiptPdfUrl); }
|
||||||
|
|
||||||
|
zoomIn(): void { this.receiptZoom = Math.min(this.maxZoom, +(this.receiptZoom + 0.25).toFixed(2)); }
|
||||||
|
zoomOut(): void { this.receiptZoom = Math.max(this.minZoom, +(this.receiptZoom - 0.25).toFixed(2)); }
|
||||||
|
resetZoom(): void { this.receiptZoom = 1; }
|
||||||
|
|
||||||
|
constructor(private api: ExpenseApiService, private sanitizer: DomSanitizer) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.api.getById(this.expenseId).subscribe({
|
||||||
|
next: dto => {
|
||||||
|
this.expense = dto;
|
||||||
|
this.loading = false;
|
||||||
|
if (dto.hasReceipt) {
|
||||||
|
this.api.downloadReceipt(dto.id).subscribe(blob => this.setPreview(blob, blob.type));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => { this.loading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void { this.clearPreview(); }
|
||||||
|
|
||||||
|
private setPreview(blob: Blob, contentType: string): void {
|
||||||
|
this.clearPreview();
|
||||||
|
this.receiptZoom = 1;
|
||||||
|
this.receiptObjectUrl = URL.createObjectURL(blob);
|
||||||
|
if (contentType.startsWith('image/')) {
|
||||||
|
this.receiptImageUrl = this.receiptObjectUrl;
|
||||||
|
} else {
|
||||||
|
this.receiptPdfUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.receiptObjectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPreview(): void {
|
||||||
|
if (this.receiptObjectUrl) { URL.revokeObjectURL(this.receiptObjectUrl); this.receiptObjectUrl = null; }
|
||||||
|
this.receiptImageUrl = null;
|
||||||
|
this.receiptPdfUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startReject(): void { this.rejecting = true; this.rejectReason = null; this.rejectOther = ''; }
|
||||||
|
cancelReject(): void { this.rejecting = false; }
|
||||||
|
|
||||||
|
get isOtherReason(): boolean { return this.rejectReason === 'Other'; }
|
||||||
|
|
||||||
|
/** The reason text actually sent: the template value, or the free text when "Other" is chosen. */
|
||||||
|
get composedReviewNotes(): string {
|
||||||
|
return this.isOtherReason ? this.rejectOther.trim() : (this.rejectReason ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
get canConfirmReject(): boolean { return this.composedReviewNotes.length > 0; }
|
||||||
|
|
||||||
|
confirmApprove(): void { this.approve.emit(); }
|
||||||
|
|
||||||
|
confirmReject(): void {
|
||||||
|
if (!this.canConfirmReject) return;
|
||||||
|
this.reject.emit(this.composedReviewNotes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export type ExpenseType = 'VendorPayment' | 'StaffReimbursement';
|
export type ExpenseType = 'VendorPayment' | 'StaffReimbursement';
|
||||||
export type ExpenseStatus = 'Draft' | 'PendingApproval' | 'Approved' | 'Paid' | 'Rejected';
|
export type ExpenseStatus = 'Draft' | 'PendingApproval' | 'Approved' | 'Paid' | 'Rejected';
|
||||||
|
export type FunctionalClass = 'Program' | 'ManagementGeneral' | 'Fundraising';
|
||||||
|
|
||||||
export interface PagedResult<T> {
|
export interface PagedResult<T> {
|
||||||
items: T[]; totalCount: number; page: number; pageSize: number; totalPages: number;
|
items: T[]; totalCount: number; page: number; pageSize: number; totalPages: number;
|
||||||
@@ -7,27 +8,38 @@ export interface PagedResult<T> {
|
|||||||
|
|
||||||
export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; }
|
export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; }
|
||||||
|
|
||||||
export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; }
|
export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; form990LineId: number | null; form990LineCode: string | null; }
|
||||||
export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; }
|
export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; form990LineId: number | null; form990LineCode: string | null; }
|
||||||
export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; }
|
export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
|
||||||
export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { isActive: boolean; }
|
export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { isActive: boolean; }
|
||||||
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; }
|
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
|
||||||
export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; }
|
export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; }
|
||||||
|
|
||||||
|
export interface ExpenseLineItemDto {
|
||||||
|
id: number; categoryGroupId: number; categoryGroupName: string;
|
||||||
|
subCategoryId: number; subCategoryName: string;
|
||||||
|
functionalClass: FunctionalClass | null; amount: number; description: string | null;
|
||||||
|
}
|
||||||
export interface ExpenseListItemDto {
|
export interface ExpenseListItemDto {
|
||||||
id: number; type: ExpenseType; status: ExpenseStatus; amount: number; description: string;
|
id: number; type: ExpenseType; status: ExpenseStatus; amount: number; description: string;
|
||||||
ministryId: number; ministryName: string; categoryGroupId: number; categoryGroupName: string;
|
ministryId: number; ministryName: string; lineCount: number; primaryCategoryName: string;
|
||||||
subCategoryId: number; subCategoryName: string; vendorName: string | null;
|
vendorName: string | null;
|
||||||
memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean;
|
memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean;
|
||||||
checkNumber: string | null;
|
checkNumber: string | null;
|
||||||
|
reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null;
|
||||||
}
|
}
|
||||||
export interface ExpenseDto extends ExpenseListItemDto {
|
export interface ExpenseDto extends ExpenseListItemDto {
|
||||||
notes: string | null; reviewNotes: string | null;
|
notes: string | null;
|
||||||
submittedBy: string | null; submittedAt: string | null; reviewedAt: string | null; paidAt: string | null;
|
submittedBy: string | null; submittedAt: string | null; paidAt: string | null;
|
||||||
|
lines: ExpenseLineItemDto[];
|
||||||
|
}
|
||||||
|
export interface ExpenseLineInput {
|
||||||
|
categoryGroupId: number; subCategoryId: number; amount: number;
|
||||||
|
functionalClass: FunctionalClass | null; description: string | null;
|
||||||
}
|
}
|
||||||
export interface CreateExpenseRequest {
|
export interface CreateExpenseRequest {
|
||||||
type: ExpenseType; ministryId: number; categoryGroupId: number; subCategoryId: number;
|
type: ExpenseType; ministryId: number; lines: ExpenseLineInput[];
|
||||||
amount: number; description: string; vendorName: string | null; memberId: number | null;
|
description: string; vendorName: string | null; memberId: number | null;
|
||||||
checkNumber: string | null; expenseDate: string; notes: string | null;
|
checkNumber: string | null; expenseDate: string; notes: string | null;
|
||||||
}
|
}
|
||||||
export type UpdateExpenseRequest = CreateExpenseRequest;
|
export type UpdateExpenseRequest = CreateExpenseRequest;
|
||||||
|
|||||||
+18
@@ -61,6 +61,15 @@
|
|||||||
Sort order
|
Sort order
|
||||||
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
|
<span>Form 990 Line / 990 行</span>
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="form990Lines"
|
||||||
|
textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
|
[defaultItem]="{ id: null, label: '(Unmapped / 未對應)' }"
|
||||||
|
[(ngModel)]="groupForm.form990LineId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
|
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
|
||||||
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
|
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
|
||||||
</label>
|
</label>
|
||||||
@@ -89,6 +98,15 @@
|
|||||||
Sort order
|
Sort order
|
||||||
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
|
<span>Form 990 Line / 990 行</span>
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="form990Lines"
|
||||||
|
textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
|
[defaultItem]="{ id: null, label: '(Unmapped / 未對應)' }"
|
||||||
|
[(ngModel)]="subForm.form990LineId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
|
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
|
||||||
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
|
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
+16
-10
@@ -5,14 +5,16 @@ import { GridModule, CellClickEvent, RowClassArgs } from '@progress/kendo-angula
|
|||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||||
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
|
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
|
||||||
|
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-expense-categories-page',
|
selector: 'app-expense-categories-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, ContextMenuModule],
|
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, DropDownsModule, ContextMenuModule],
|
||||||
templateUrl: './expense-categories-page.component.html',
|
templateUrl: './expense-categories-page.component.html',
|
||||||
styleUrls: ['./expense-categories-page.component.scss'],
|
styleUrls: ['./expense-categories-page.component.scss'],
|
||||||
})
|
})
|
||||||
@@ -20,6 +22,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
groups: ExpenseCategoryGroupDto[] = [];
|
groups: ExpenseCategoryGroupDto[] = [];
|
||||||
selectedGroup: ExpenseCategoryGroupDto | null = null;
|
selectedGroup: ExpenseCategoryGroupDto | null = null;
|
||||||
loading = false;
|
loading = false;
|
||||||
|
form990Lines: Form990ExpenseLineDto[] = [];
|
||||||
|
|
||||||
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
|
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
|
||||||
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
|
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
|
||||||
@@ -30,15 +33,18 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
|
|
||||||
groupDialogOpen = false;
|
groupDialogOpen = false;
|
||||||
editingGroupId: number | null = null;
|
editingGroupId: number | null = null;
|
||||||
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true };
|
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
||||||
|
|
||||||
subDialogOpen = false;
|
subDialogOpen = false;
|
||||||
editingSubId: number | null = null;
|
editingSubId: number | null = null;
|
||||||
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true };
|
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
||||||
|
|
||||||
constructor(private api: ExpenseCategoryApiService) {}
|
constructor(private api: ExpenseCategoryApiService) {}
|
||||||
|
|
||||||
ngOnInit(): void { this.load(); }
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; });
|
||||||
|
}
|
||||||
|
|
||||||
load(): void {
|
load(): void {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
@@ -101,16 +107,16 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
|
|
||||||
openNewGroup(): void {
|
openNewGroup(): void {
|
||||||
this.editingGroupId = null;
|
this.editingGroupId = null;
|
||||||
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true };
|
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
|
||||||
this.groupDialogOpen = true;
|
this.groupDialogOpen = true;
|
||||||
}
|
}
|
||||||
openEditGroup(g: ExpenseCategoryGroupDto): void {
|
openEditGroup(g: ExpenseCategoryGroupDto): void {
|
||||||
this.editingGroupId = g.id;
|
this.editingGroupId = g.id;
|
||||||
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive };
|
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
|
||||||
this.groupDialogOpen = true;
|
this.groupDialogOpen = true;
|
||||||
}
|
}
|
||||||
saveGroup(): void {
|
saveGroup(): void {
|
||||||
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder };
|
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
|
||||||
const done = () => { this.groupDialogOpen = false; this.load(); };
|
const done = () => { this.groupDialogOpen = false; this.load(); };
|
||||||
if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
|
if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
|
||||||
else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done);
|
else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done);
|
||||||
@@ -123,17 +129,17 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
openNewSub(): void {
|
openNewSub(): void {
|
||||||
if (!this.selectedGroup) return;
|
if (!this.selectedGroup) return;
|
||||||
this.editingSubId = null;
|
this.editingSubId = null;
|
||||||
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true };
|
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
|
||||||
this.subDialogOpen = true;
|
this.subDialogOpen = true;
|
||||||
}
|
}
|
||||||
openEditSub(s: ExpenseSubCategoryDto): void {
|
openEditSub(s: ExpenseSubCategoryDto): void {
|
||||||
this.editingSubId = s.id;
|
this.editingSubId = s.id;
|
||||||
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive };
|
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
|
||||||
this.subDialogOpen = true;
|
this.subDialogOpen = true;
|
||||||
}
|
}
|
||||||
saveSub(): void {
|
saveSub(): void {
|
||||||
if (!this.selectedGroup) return;
|
if (!this.selectedGroup) return;
|
||||||
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder };
|
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
|
||||||
const done = () => { this.subDialogOpen = false; this.load(); };
|
const done = () => { this.subDialogOpen = false; this.load(); };
|
||||||
if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
|
if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
|
||||||
else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
|
else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
<kendo-grid-column title="Category" [width]="360">
|
<kendo-grid-column title="Category" [width]="360">
|
||||||
<ng-template kendoGridCellTemplate let-dataItem>
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
|
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1" class="text-gray-500"> +{{ dataItem.lineCount - 1 }}</span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
|
|
||||||
@@ -62,19 +62,23 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
|
|
||||||
<kendo-grid-column title="Status" [width]="140">
|
<kendo-grid-column title="Status" [width]="200">
|
||||||
<ng-template kendoGridCellTemplate let-dataItem>
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
||||||
|
<div *ngIf="dataItem.reviewedByName && (dataItem.status === 'Approved' || dataItem.status === 'Paid')"
|
||||||
|
class="review-meta">✓ Approved by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}</div>
|
||||||
|
<div *ngIf="dataItem.reviewedByName && dataItem.status === 'Rejected'" class="review-meta review-meta-reject">
|
||||||
|
✗ Rejected by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
|
||||||
|
<div *ngIf="dataItem.reviewNotes" class="review-reason">{{ dataItem.reviewNotes }}</div>
|
||||||
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
|
|
||||||
<kendo-grid-column title="Actions" [width]="160">
|
<kendo-grid-column title="Actions" [width]="160">
|
||||||
<ng-template kendoGridCellTemplate let-dataItem>
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
||||||
<ng-container *ngIf="canApproveOrReject(dataItem)">
|
<button *ngIf="canApproveOrReject(dataItem)" kendoButton themeColor="primary" fillMode="flat"
|
||||||
<button kendoButton themeColor="success" fillMode="flat" (click)="approve(dataItem)">Approve</button>
|
(click)="openReview(dataItem)">Review</button>
|
||||||
<button kendoButton themeColor="error" fillMode="flat" (click)="openReject(dataItem)">Reject</button>
|
|
||||||
</ng-container>
|
|
||||||
<button *ngIf="canPay(dataItem)" kendoButton themeColor="primary" fillMode="flat"
|
<button *ngIf="canPay(dataItem)" kendoButton themeColor="primary" fillMode="flat"
|
||||||
(click)="openPay(dataItem)">Pay</button>
|
(click)="openPay(dataItem)">Pay</button>
|
||||||
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(dataItem.id)"
|
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(dataItem.id)"
|
||||||
@@ -118,19 +122,10 @@
|
|||||||
</kendo-dialog-actions>
|
</kendo-dialog-actions>
|
||||||
</kendo-dialog>
|
</kendo-dialog>
|
||||||
|
|
||||||
<!-- Reject dialog -->
|
<!-- Review dialog: detail + receipt preview, with Approve / Reject(reason) -->
|
||||||
<kendo-dialog *ngIf="rejectRow" title="Reject Expense" [width]="400" [maxWidth]="'95vw'" (close)="rejectRow = null">
|
<app-expense-review-dialog *ngIf="reviewRow" [expenseId]="reviewRow.id"
|
||||||
<div class="grid grid-cols-1 gap-3 p-2">
|
(approve)="onReviewApprove()" (reject)="onReviewReject($event)" (cancel)="closeReview()">
|
||||||
<label class="flex flex-col gap-1">
|
</app-expense-review-dialog>
|
||||||
Review Notes
|
|
||||||
<kendo-textbox [(ngModel)]="rejectNotes" placeholder="Optional notes for submitter"></kendo-textbox>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<kendo-dialog-actions>
|
|
||||||
<button kendoButton (click)="rejectRow = null">Cancel</button>
|
|
||||||
<button kendoButton themeColor="error" (click)="confirmReject()">Reject</button>
|
|
||||||
</kendo-dialog-actions>
|
|
||||||
</kendo-dialog>
|
|
||||||
|
|
||||||
<!-- Transient save confirmation (sits above the open dialog during continuous entry) -->
|
<!-- Transient save confirmation (sits above the open dialog during continuous entry) -->
|
||||||
<div *ngIf="toast" class="save-toast">{{ toast }}</div>
|
<div *ngIf="toast" class="save-toast">{{ toast }}</div>
|
||||||
|
|||||||
@@ -45,6 +45,24 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Small "Approved/Rejected by X · date" note under the status badge.
|
||||||
|
.review-meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-meta-reject {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-reason {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-style: italic;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
// Save confirmation pill. z-index sits above the Kendo dialog overlay so it
|
// Save confirmation pill. z-index sits above the Kendo dialog overlay so it
|
||||||
// stays visible while the continuous-entry dialog remains open.
|
// stays visible while the continuous-entry dialog remains open.
|
||||||
.save-toast {
|
.save-toast {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { EXPENSE_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
|
|||||||
import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service';
|
import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service';
|
||||||
import { MinistryApiService } from '../../services/ministry-api.service';
|
import { MinistryApiService } from '../../services/ministry-api.service';
|
||||||
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
|
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
|
||||||
import { ExpenseListItemDto, MinistryDto } from '../../models/expense.model';
|
import { ExpenseReviewDialogComponent } from '../../components/expense-review-dialog/expense-review-dialog.component';
|
||||||
|
import { ExpenseDto, ExpenseListItemDto, MinistryDto } from '../../models/expense.model';
|
||||||
import { switchMap, of } from 'rxjs';
|
import { switchMap, of } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -20,6 +21,7 @@ import { switchMap, of } from 'rxjs';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
|
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
|
||||||
InputsModule, DialogsModule, DateInputsModule, ExpenseFormDialogComponent,
|
InputsModule, DialogsModule, DateInputsModule, ExpenseFormDialogComponent,
|
||||||
|
ExpenseReviewDialogComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './expenses-page.component.html',
|
templateUrl: './expenses-page.component.html',
|
||||||
styleUrls: ['./expenses-page.component.scss'],
|
styleUrls: ['./expenses-page.component.scss'],
|
||||||
@@ -39,15 +41,15 @@ export class ExpensesPageComponent implements OnInit {
|
|||||||
vendorDialogOpen = false;
|
vendorDialogOpen = false;
|
||||||
reimbDialogOpen = false;
|
reimbDialogOpen = false;
|
||||||
|
|
||||||
editRow: ExpenseListItemDto | null = null;
|
editRow: ExpenseDto | null = null;
|
||||||
editMode: 'vendor' | 'reimbursement' = 'reimbursement';
|
editMode: 'vendor' | 'reimbursement' = 'reimbursement';
|
||||||
|
|
||||||
payRow: ExpenseListItemDto | null = null;
|
payRow: ExpenseListItemDto | null = null;
|
||||||
payCheckNumber = '';
|
payCheckNumber = '';
|
||||||
payDate = new Date();
|
payDate = new Date();
|
||||||
|
|
||||||
rejectRow: ExpenseListItemDto | null = null;
|
/** Row whose detail+receipt are open in the review dialog for an approve/reject decision. */
|
||||||
rejectNotes = '';
|
reviewRow: ExpenseListItemDto | null = null;
|
||||||
|
|
||||||
/** Transient confirmation pill, used so the user gets feedback during continuous entry. */
|
/** Transient confirmation pill, used so the user gets feedback during continuous entry. */
|
||||||
toast: string | null = null;
|
toast: string | null = null;
|
||||||
@@ -95,8 +97,9 @@ export class ExpensesPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openEdit(row: ExpenseListItemDto): void {
|
openEdit(row: ExpenseListItemDto): void {
|
||||||
this.editRow = row;
|
// Fetch the full expense (with its lines) before opening the dialog for editing.
|
||||||
this.editMode = row.type === 'VendorPayment' ? 'vendor' : 'reimbursement';
|
this.editMode = row.type === 'VendorPayment' ? 'vendor' : 'reimbursement';
|
||||||
|
this.api.getById(row.id).subscribe(dto => (this.editRow = dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
closeEdit(): void { this.editRow = null; }
|
closeEdit(): void { this.editRow = null; }
|
||||||
@@ -109,19 +112,18 @@ export class ExpensesPageComponent implements OnInit {
|
|||||||
).subscribe(() => { this.closeEdit(); this.load(); });
|
).subscribe(() => { this.closeEdit(); this.load(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
approve(row: ExpenseListItemDto): void {
|
openReview(row: ExpenseListItemDto): void { this.reviewRow = row; }
|
||||||
this.api.approve(row.id).subscribe(() => this.load());
|
closeReview(): void { this.reviewRow = null; }
|
||||||
|
|
||||||
|
onReviewApprove(): void {
|
||||||
|
if (!this.reviewRow) return;
|
||||||
|
this.api.approve(this.reviewRow.id).subscribe(() => { this.reviewRow = null; this.load(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
openReject(row: ExpenseListItemDto): void {
|
onReviewReject(reviewNotes: string): void {
|
||||||
this.rejectRow = row;
|
if (!this.reviewRow) return;
|
||||||
this.rejectNotes = '';
|
this.api.reject(this.reviewRow.id, { reviewNotes: reviewNotes || null }).subscribe(() => {
|
||||||
}
|
this.reviewRow = null;
|
||||||
|
|
||||||
confirmReject(): void {
|
|
||||||
if (!this.rejectRow) return;
|
|
||||||
this.api.reject(this.rejectRow.id, { reviewNotes: this.rejectNotes || null }).subscribe(() => {
|
|
||||||
this.rejectRow = null;
|
|
||||||
this.load();
|
this.load();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-4
@@ -11,19 +11,26 @@
|
|||||||
<kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column>
|
<kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column>
|
||||||
<kendo-grid-column title="Category">
|
<kendo-grid-column title="Category">
|
||||||
<ng-template kendoGridCellTemplate let-dataItem>
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
|
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1" class="text-gray-500"> +{{ dataItem.lineCount - 1 }}</span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
|
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
|
||||||
<kendo-grid-column title="Status" [width]="140">
|
<kendo-grid-column title="Status" [width]="220">
|
||||||
<ng-template kendoGridCellTemplate let-dataItem>
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
|
||||||
|
<div *ngIf="dataItem.reviewedByName && (dataItem.status === 'Approved' || dataItem.status === 'Paid')"
|
||||||
|
class="review-meta">✓ Approved by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}</div>
|
||||||
|
<div *ngIf="dataItem.status === 'Rejected'" class="review-meta review-meta-reject">
|
||||||
|
✗ Rejected<span *ngIf="dataItem.reviewedByName"> by {{ dataItem.reviewedByName }}</span><br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
|
||||||
|
<div *ngIf="dataItem.reviewNotes" class="review-reason">{{ dataItem.reviewNotes }}</div>
|
||||||
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-grid-column>
|
</kendo-grid-column>
|
||||||
<kendo-grid-column title="Actions" [width]="200">
|
<kendo-grid-column title="Actions" [width]="230">
|
||||||
<ng-template kendoGridCellTemplate let-dataItem>
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
|
||||||
<button *ngIf="isDraft(dataItem)" kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
|
<button *ngIf="isDraft(dataItem)" kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
|
||||||
|
<button *ngIf="isRejected(dataItem)" kendoButton themeColor="primary" fillMode="flat" (click)="resubmit(dataItem)">Resubmit</button>
|
||||||
<button *ngIf="isDraft(dataItem)" kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
|
<button *ngIf="isDraft(dataItem)" kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
|
||||||
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat"
|
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat"
|
||||||
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
|
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
|
||||||
@@ -52,7 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Category</dt>
|
<dt>Category</dt>
|
||||||
<dd>{{ row.categoryGroupName }} / {{ row.subCategoryName }}</dd>
|
<dd>{{ row.primaryCategoryName }}<span *ngIf="row.lineCount > 1"> +{{ row.lineCount - 1 }}</span></dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
@@ -60,9 +67,20 @@
|
|||||||
<span [class]="statusClass(row.status)">{{ row.status }}</span>
|
<span [class]="statusClass(row.status)">{{ row.status }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Rejection feedback: what the submitter must fix before resubmitting -->
|
||||||
|
<div *ngIf="row.status === 'Rejected'" class="rmb-card__reject">
|
||||||
|
✗ Rejected<span *ngIf="row.reviewedByName"> by {{ row.reviewedByName }}</span>
|
||||||
|
<span *ngIf="row.reviewedAt"> · {{ row.reviewedAt | date:'yyyy-MM-dd HH:mm' }}</span>
|
||||||
|
<div *ngIf="row.reviewNotes" class="rmb-card__reject-reason">{{ row.reviewNotes }}</div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="row.reviewedByName && (row.status === 'Approved' || row.status === 'Paid')" class="rmb-card__approved">
|
||||||
|
✓ Approved by {{ row.reviewedByName }} · {{ row.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rmb-card__actions" *ngIf="canEdit(row) || row.hasReceipt">
|
<div class="rmb-card__actions" *ngIf="canEdit(row) || row.hasReceipt">
|
||||||
<button *ngIf="canEdit(row)" kendoButton fillMode="outline" (click)="openEdit(row)">Edit</button>
|
<button *ngIf="canEdit(row)" kendoButton fillMode="outline" (click)="openEdit(row)">Edit</button>
|
||||||
<button *ngIf="isDraft(row)" kendoButton themeColor="primary" (click)="submit(row)">Submit</button>
|
<button *ngIf="isDraft(row)" kendoButton themeColor="primary" (click)="submit(row)">Submit</button>
|
||||||
|
<button *ngIf="isRejected(row)" kendoButton themeColor="primary" (click)="resubmit(row)">Resubmit</button>
|
||||||
<button *ngIf="isDraft(row)" kendoButton fillMode="outline" (click)="remove(row)">Delete</button>
|
<button *ngIf="isDraft(row)" kendoButton fillMode="outline" (click)="remove(row)">Delete</button>
|
||||||
<button *ngIf="row.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(row.id)">Receipt</button>
|
<button *ngIf="row.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(row.id)">Receipt</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+40
@@ -45,6 +45,24 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Small "Approved/Rejected by X · date" note under the status badge (desktop grid).
|
||||||
|
.review-meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-meta-reject {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-reason {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-style: italic;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
// Mobile card list
|
// Mobile card list
|
||||||
// NOTE: display/flex layout lives on the element via Tailwind (flex flex-col gap-3)
|
// NOTE: display/flex layout lives on the element via Tailwind (flex flex-col gap-3)
|
||||||
// so the responsive `md:hidden` utility wins on desktop. Setting `display: flex`
|
// so the responsive `md:hidden` utility wins on desktop. Setting `display: flex`
|
||||||
@@ -116,6 +134,28 @@
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__reject {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reject-reason {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__approved {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
|
|||||||
+14
-5
@@ -4,7 +4,7 @@ import { GridModule } from '@progress/kendo-angular-grid';
|
|||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { ExpenseApiService } from '../../services/expense-api.service';
|
import { ExpenseApiService } from '../../services/expense-api.service';
|
||||||
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
|
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
|
||||||
import { ExpenseListItemDto } from '../../models/expense.model';
|
import { ExpenseDto, ExpenseListItemDto } from '../../models/expense.model';
|
||||||
import { switchMap, of } from 'rxjs';
|
import { switchMap, of } from 'rxjs';
|
||||||
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export class MyReimbursementsPageComponent implements OnInit {
|
|||||||
rows: ExpenseListItemDto[] = [];
|
rows: ExpenseListItemDto[] = [];
|
||||||
loading = false;
|
loading = false;
|
||||||
dialogOpen = false;
|
dialogOpen = false;
|
||||||
editRow: ExpenseListItemDto | null = null;
|
editRow: ExpenseDto | null = null;
|
||||||
|
|
||||||
constructor(private api: ExpenseApiService) {}
|
constructor(private api: ExpenseApiService) {}
|
||||||
|
|
||||||
@@ -34,7 +34,10 @@ export class MyReimbursementsPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openNew(): void { this.editRow = null; this.dialogOpen = true; }
|
openNew(): void { this.editRow = null; this.dialogOpen = true; }
|
||||||
openEdit(row: ExpenseListItemDto): void { this.editRow = row; this.dialogOpen = true; }
|
openEdit(row: ExpenseListItemDto): void {
|
||||||
|
// Fetch the full expense (with its lines) before opening the dialog for editing.
|
||||||
|
this.api.getById(row.id).subscribe(dto => { this.editRow = dto; this.dialogOpen = true; });
|
||||||
|
}
|
||||||
closeDialog(): void { this.dialogOpen = false; this.editRow = null; }
|
closeDialog(): void { this.dialogOpen = false; this.editRow = null; }
|
||||||
|
|
||||||
onSave(result: ExpenseFormResult): void {
|
onSave(result: ExpenseFormResult): void {
|
||||||
@@ -53,6 +56,8 @@ export class MyReimbursementsPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); }
|
submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); }
|
||||||
|
/** Re-submit a rejected reimbursement after fixing the flagged issue (clears the prior review server-side). */
|
||||||
|
resubmit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); }
|
||||||
remove(row: ExpenseListItemDto): void {
|
remove(row: ExpenseListItemDto): void {
|
||||||
if (!confirm('Delete this reimbursement?')) return;
|
if (!confirm('Delete this reimbursement?')) return;
|
||||||
this.api.delete(row.id).subscribe(() => this.load());
|
this.api.delete(row.id).subscribe(() => this.load());
|
||||||
@@ -66,10 +71,14 @@ export class MyReimbursementsPageComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Editing (and reuploading the photo) is allowed while a reimbursement is still Draft or awaiting review. */
|
/** Editing (and reuploading the photo) is allowed while Draft, awaiting review, or after a rejection. */
|
||||||
canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft' || row.status === 'PendingApproval'; }
|
canEdit(row: ExpenseListItemDto): boolean {
|
||||||
|
return row.status === 'Draft' || row.status === 'PendingApproval' || row.status === 'Rejected';
|
||||||
|
}
|
||||||
/** Submit and Delete only apply before the reimbursement has been submitted. */
|
/** Submit and Delete only apply before the reimbursement has been submitted. */
|
||||||
isDraft(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; }
|
isDraft(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; }
|
||||||
|
/** A rejected reimbursement can be fixed and re-submitted by its owner. */
|
||||||
|
isRejected(row: ExpenseListItemDto): boolean { return row.status === 'Rejected'; }
|
||||||
statusClass(status: string): string {
|
statusClass(status: string): string {
|
||||||
return ({ Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' } as Record<string, string>)[status] ?? '';
|
return ({ Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' } as Record<string, string>)[status] ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user