update detail.
ci-cd-vm / ci-cd (push) Successful in 4m27s

This commit is contained in:
Chris Chen
2026-06-25 09:33:49 -07:00
parent 609ce6a439
commit 8bdb942a49
23 changed files with 698 additions and 271 deletions
@@ -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()
{ {
@@ -67,14 +67,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 +213,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);
@@ -260,13 +266,70 @@ public class ExpenseServiceTests
var id = await svc.CreateAsync(new CreateExpenseRequest var id = await svc.CreateAsync(new CreateExpenseRequest
{ {
Type = "VendorPayment", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "VendorPayment", MinistryId = 1,
Amount = 50m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 1), Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m, FunctionalClass = "ManagementGeneral" } },
FunctionalClass = "ManagementGeneral", Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
}, isFinance: true); }, isFinance: true);
var dto = await svc.GetByIdAsync(id); var dto = await svc.GetByIdAsync(id);
Assert.Equal("ManagementGeneral", dto!.FunctionalClass); 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]
@@ -36,9 +36,9 @@ public class Form990ReportServiceTests
private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new() private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new()
{ {
MinistryId = min, CategoryGroupId = 1, SubCategoryId = sub, Type = "VendorPayment", MinistryId = min, Type = "VendorPayment",
Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10), Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10),
FunctionalClass = fc, Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = sub, Amount = amt, FunctionalClass = fc } },
}; };
[Fact] [Fact]
@@ -82,4 +82,31 @@ public class Form990ReportServiceTests
var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31)); var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31));
Assert.Equal(100m, stmt.GrandTotal); 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
}
} }
@@ -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);
+26 -10
View File
@@ -1,26 +1,35 @@
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; }
public string? FunctionalClass { get; set; }
} }
public class ExpenseDto : ExpenseListItemDto public class ExpenseDto : ExpenseListItemDto
@@ -31,22 +40,29 @@ public class ExpenseDto : ExpenseListItemDto
public DateTimeOffset? SubmittedAt { get; set; } public DateTimeOffset? SubmittedAt { get; set; }
public DateTimeOffset? ReviewedAt { 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)
[MaxLength(50)] public string? CheckNumber { get; set; } [MaxLength(50)] public string? CheckNumber { get; set; }
[Required] public DateOnly ExpenseDate { get; set; } [Required] public DateOnly ExpenseDate { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
[MaxLength(20)] public string? FunctionalClass { get; set; }
} }
public class UpdateExpenseRequest : CreateExpenseRequest { } public class UpdateExpenseRequest : CreateExpenseRequest { }
+21 -3
View File
@@ -22,6 +22,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>(); public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>(); 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>();
@@ -246,7 +247,6 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Type).HasMaxLength(30).IsRequired(); entity.Property(e => e.Type).HasMaxLength(30).IsRequired();
entity.Property(e => e.Status).HasMaxLength(30).HasDefaultValue("Draft"); entity.Property(e => e.Status).HasMaxLength(30).HasDefaultValue("Draft");
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)"); entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.Description).HasMaxLength(500).IsRequired(); entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
entity.Property(e => e.VendorName).HasMaxLength(200); entity.Property(e => e.VendorName).HasMaxLength(200);
@@ -266,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) ───────────────────────────────
+14 -2
View File
@@ -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;
+4 -8
View File
@@ -5,12 +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 string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass public decimal Amount { get; set; } // denormalized total = SUM(Lines.Amount), recomputed server-side
public decimal Amount { get; set; }
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,8 +23,7 @@ public class Expense : SoftDeleteEntity, IAuditable
public DateTimeOffset? PaidAt { get; set; } public DateTimeOffset? PaidAt { get; set; }
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 Member? Member { get; set; }
public ExpenseSubCategory? SubCategory { get; set; } public List<ExpenseLine> Lines { get; set; } = new();
public Member? Member { get; set; }
} }
+23
View File
@@ -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; }
}
+1 -1
View File
@@ -2,7 +2,7 @@ namespace ROLAC.API.Entities;
/// <summary> /// <summary>
/// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in /// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in
/// Ministry.DefaultFunctionalClass and Expense.FunctionalClass. /// Ministry.DefaultFunctionalClass and ExpenseLine.FunctionalClass.
/// </summary> /// </summary>
public static class FunctionalClasses public static class FunctionalClasses
{ {
@@ -525,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)");
@@ -555,10 +552,6 @@ namespace ROLAC.API.Migrations
b.Property<DateOnly>("ExpenseDate") b.Property<DateOnly>("ExpenseDate")
.HasColumnType("date"); .HasColumnType("date");
b.Property<string>("FunctionalClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -600,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");
@@ -629,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");
@@ -640,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");
}); });
@@ -694,6 +680,61 @@ namespace ROLAC.API.Migrations
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")
@@ -2007,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")
@@ -2024,19 +2059,9 @@ namespace ROLAC.API.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory")
.WithMany()
.HasForeignKey("SubCategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CategoryGroup");
b.Navigation("Member"); b.Navigation("Member");
b.Navigation("Ministry"); b.Navigation("Ministry");
b.Navigation("SubCategory");
}); });
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
@@ -2049,6 +2074,33 @@ namespace ROLAC.API.Migrations
b.Navigation("Form990Line"); 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")
.WithMany()
.HasForeignKey("SubCategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CategoryGroup");
b.Navigation("Expense");
b.Navigation("SubCategory");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
{ {
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line") b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
@@ -2184,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");
+14 -1
View File
@@ -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;
} }
+78 -26
View File
@@ -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,23 +82,36 @@ 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 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 =>
{ {
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description, linesByExpense.TryGetValue(e.Id, out var ls);
MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""), var firstGroupId = ls is { Count: > 0 } ? ls[0].CategoryGroupId : 0;
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""), return new ExpenseListItemDto
SubCategoryId = e.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(e.SubCategoryId, ""), {
VendorName = e.VendorName, MemberId = e.MemberId, Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null, MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), LineCount = ls?.Count ?? 0,
HasReceipt = e.ReceiptBlobPath != null, PrimaryCategoryName = grpNames.GetValueOrDefault(firstGroupId, ""),
CheckNumber = e.CheckNumber, VendorName = e.VendorName, MemberId = e.MemberId,
FunctionalClass = e.FunctionalClass, MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null,
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber,
};
}).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 };
@@ -108,33 +122,66 @@ public class ExpenseService : IExpenseService
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 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, SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt,
FunctionalClass = e.FunctionalClass, 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,
FunctionalClass = r.FunctionalClass, Lines = BuildLines(r.Lines),
}; };
if (r.Type == "VendorPayment") if (r.Type == "VendorPayment")
@@ -174,16 +221,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")))
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements."); throw new InvalidOperationException("You can only edit your own draft or pending 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; e.FunctionalClass = r.FunctionalClass;
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();
} }
@@ -53,17 +53,24 @@ public class FinanceDashboardService : IFinanceDashboardService
DateOnly? from, DateOnly? to, int? ministryId, int? categoryGroupId) DateOnly? from, DateOnly? to, int? ministryId, int? categoryGroupId)
{ {
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
@@ -8,7 +8,7 @@ namespace ROLAC.API.Services;
/// <summary> /// <summary>
/// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional /// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional
/// Expenses. Expense scope matches FinanceDashboardService: Paid + Approved only. /// Expenses. Expense scope matches FinanceDashboardService: Paid + Approved only.
/// Single function per expense (direct-charge); no cost splitting. /// Each expense line is categorized independently, so one invoice can span multiple lines.
/// </summary> /// </summary>
public class Form990ReportService : IForm990ReportService public class Form990ReportService : IForm990ReportService
{ {
@@ -40,13 +40,14 @@ public class Form990ReportService : IForm990ReportService
var rows = await ( var rows = await (
from e in expenses 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 m in _db.Ministries on e.MinistryId equals m.Id
join sub in _db.ExpenseSubCategories on e.SubCategoryId equals sub.Id join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id
join grp in _db.ExpenseCategoryGroups on e.CategoryGroupId equals grp.Id join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id
select new select new
{ {
e.Amount, l.Amount,
e.FunctionalClass, l.FunctionalClass,
MinistryDefault = m.DefaultFunctionalClass, MinistryDefault = m.DefaultFunctionalClass,
SubLineId = sub.Form990LineId, SubLineId = sub.Form990LineId,
GroupLineId = grp.Form990LineId, GroupLineId = grp.Form990LineId,
@@ -1,128 +1,147 @@
<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">
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) --> <div class="flex-1 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
<span>連續登打 / Continuous Entry</span>
</label>
<!-- Member picker (finance creating on behalf of a member) --> <!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member <label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
<kendo-dropdownlist <kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
[data]="memberResults" <span>連續登打 / Continuous Entry</span>
textField="displayName" </label>
valueField="id"
[valuePrimitive]="true" <!-- Description -->
[filterable]="true" <label class="flex flex-col gap-1 md:col-span-2">Description
(filterChange)="onMemberFilter($event)" <kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
[(ngModel)]="form.memberId" </label>
<!-- Member picker (finance creating on behalf of a member) -->
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
<kendo-dropdownlist [data]="memberResults" 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>
<!-- Ministry -->
<label class="flex flex-col gap-1">Ministry
<kendo-dropdownlist
[data]="ministries"
textField="label"
valueField="id"
[valuePrimitive]="true"
[(ngModel)]="form.ministryId"
[defaultItem]="{ id: null, label: '-- Select ministry --/請選擇事工' }">
</kendo-dropdownlist>
</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>
<!-- Functional Class override -->
<label class="flex flex-col gap-1">
<span>Functional Class / 功能別</span>
<kendo-dropdownlist
[data]="functionalClassOptions"
textField="label"
valueField="value"
[valuePrimitive]="true"
[defaultItem]="{ value: null, label: '(Inherit ministry / 沿用事工)' }"
[(ngModel)]="form.functionalClass">
</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 -->
<label class="flex flex-col gap-1">Expense Date
<kendo-datepicker [(ngModel)]="form.expenseDate"></kendo-datepicker>
</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>
<!-- Vendor mode: vendor name + check number -->
<ng-container *ngIf="mode === 'vendor'">
<label class="flex flex-col gap-1">Vendor Name
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
</label> </label>
<label class="flex flex-col gap-1">Check #
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
</label>
</ng-container>
<!-- Reimbursement mode: receipt file input --> <!-- Ministry -->
<ng-container *ngIf="mode === 'reimbursement'"> <label class="flex flex-col gap-1">Ministry
<label class="flex flex-col gap-1 md:col-span-2">Receipt (optional) <kendo-dropdownlist [data]="ministries" textField="label" valueField="id" [valuePrimitive]="true"
<!-- [(ngModel)]="form.ministryId" [defaultItem]="{ id: null, label: '-- Select ministry --/請選擇事工' }">
</kendo-dropdownlist>
</label>
<!-- Expense Date -->
<label class="flex flex-col gap-1">Expense Date
<kendo-datepicker [(ngModel)]="form.expenseDate"></kendo-datepicker>
</label>
<!-- Category lines: one invoice can span several categories -->
<div class="md:col-span-2 flex flex-col gap-2">
<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 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 -->
<ng-container *ngIf="mode === 'vendor'">
<label class="flex flex-col gap-1">Vendor Name
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">Check #
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
</label>
</ng-container>
<!-- Reimbursement mode: receipt file input -->
<ng-container *ngIf="mode === 'reimbursement'">
<label class="flex flex-col gap-1 md:col-span-2">Receipt (optional)
<!--
Stop the native 'cancel' DOM event (fired when the OS file picker is dismissed) Stop the native 'cancel' DOM event (fired when the OS file picker is dismissed)
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>
<!-- /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> </div>
<!-- /two-column body -->
<kendo-dialog-actions> <kendo-dialog-actions>
<button kendoButton (click)="cancel.emit()">Cancel</button> <button kendoButton (click)="cancel.emit()">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button> <button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
</kendo-dialog-actions> </kendo-dialog-actions>
</kendo-dialog> </kendo-dialog>
@@ -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, FunctionalClass, 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,19 +59,12 @@ export class ExpenseFormDialogComponent implements OnInit {
ministries: MinistryDto[] = []; ministries: MinistryDto[] = [];
groups: ExpenseCategoryGroupDto[] = []; groups: ExpenseCategoryGroupDto[] = [];
subs: ExpenseSubCategoryDto[] = [];
memberResults: MemberOption[] = []; memberResults: MemberOption[] = [];
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */ /** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
continueEntry = false; continueEntry = false;
readonly functionalClassOptions: { value: FunctionalClass; label: string }[] = [
{ value: 'Program', label: 'Program / 事工服務' },
{ value: 'ManagementGeneral', label: 'Management & General / 管理' },
{ value: 'Fundraising', label: 'Fundraising / 募款' },
];
/** The on-behalf reimbursement create flow is the only place continuous entry applies. */ /** The on-behalf reimbursement create flow is the only place continuous entry applies. */
get showContinueEntry(): boolean { get showContinueEntry(): boolean {
return this.mode === 'reimbursement' && this.allowMemberPick && !this.expense; return this.mode === 'reimbursement' && this.allowMemberPick && !this.expense;
@@ -65,56 +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(),
functionalClass: null as FunctionalClass | null,
}; };
/** 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),
functionalClass: expense.functionalClass ?? null,
}; };
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 {
@@ -130,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 {
@@ -145,16 +226,19 @@ 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,
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null, checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
expenseDate, expenseDate,
notes: null, notes: null,
functionalClass: this.form.functionalClass,
}; };
// The request and receipt are snapshotted here, so resetting the form right // The request and receipt are snapshotted here, so resetting the form right
// after emitting is safe even though the parent saves asynchronously. // after emitting is safe even though the parent saves asynchronously.
@@ -163,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 = '';
} }
} }
@@ -15,21 +15,31 @@ export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { i
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; } 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; functionalClass: FunctionalClass | null; checkNumber: string | null;
} }
export interface ExpenseDto extends ExpenseListItemDto { export interface ExpenseDto extends ExpenseListItemDto {
notes: string | null; reviewNotes: string | null; notes: string | null; reviewNotes: string | null;
submittedBy: string | null; submittedAt: string | null; reviewedAt: string | null; paidAt: string | null; submittedBy: string | null; submittedAt: string | null; reviewedAt: 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; functionalClass: FunctionalClass | null; checkNumber: string | null; expenseDate: string; notes: string | null;
} }
export type UpdateExpenseRequest = CreateExpenseRequest; export type UpdateExpenseRequest = CreateExpenseRequest;
export interface RejectExpenseRequest { reviewNotes: string | null; } export interface RejectExpenseRequest { reviewNotes: string | null; }
@@ -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>
@@ -11,7 +11,7 @@ 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 { ExpenseDto, ExpenseListItemDto, MinistryDto } from '../../models/expense.model';
import { switchMap, of } from 'rxjs'; import { switchMap, of } from 'rxjs';
@Component({ @Component({
@@ -39,7 +39,7 @@ 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;
@@ -95,8 +95,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; }
@@ -11,7 +11,7 @@
<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>
@@ -52,7 +52,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>
@@ -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 {
@@ -151,7 +151,7 @@
<ng-template kendoGridCellTemplate let-d> <ng-template kendoGridCellTemplate let-d>
<div class="cell-item"> <div class="cell-item">
<span class="cell-item__desc">{{ d.description }}</span> <span class="cell-item__desc">{{ d.description }}</span>
<span class="cell-item__sub">{{ d.ministryName }} · {{ d.subCategoryName }}</span> <span class="cell-item__sub">{{ d.ministryName }} · {{ d.primaryCategoryName }}<span *ngIf="d.lineCount > 1"> +{{ d.lineCount - 1 }}</span></span>
</div> </div>
</ng-template> </ng-template>
</kendo-grid-column> </kendo-grid-column>