diff --git a/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs b/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs index 643acca..050e329 100644 --- a/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/DisbursementServiceTests.cs @@ -65,6 +65,8 @@ public class DisbursementServiceTests var db = BuildDb(userId); db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 }); db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" }); + db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" }); + db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" }); db.SaveChanges(); var fs = new FakeStorage(); 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() { 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, + Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = amount } }, }; [Fact] @@ -97,6 +100,28 @@ public class DisbursementServiceTests 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] public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers() { diff --git a/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs index ed7d225..d3ccd0e 100644 --- a/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs @@ -67,14 +67,20 @@ public class ExpenseServiceTests private static CreateExpenseRequest Reimb() => new() { - Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, - Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28), + Type = "StaffReimbursement", MinistryId = 1, + 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() { - Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, - SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description, + Type = r.Type, MinistryId = r.MinistryId, + 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, ExpenseDate = r.ExpenseDate, Notes = r.Notes, }; @@ -207,7 +213,7 @@ public class ExpenseServiceTests Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status); var edit = CloneToUpdate(Reimb()); - edit.Amount = 99.99m; + edit.Lines[0].Amount = 99.99m; await svc.UpdateAsync(id, edit, isFinance: false); var e = await db.Expenses.FindAsync(id); @@ -260,13 +266,70 @@ public class ExpenseServiceTests var id = await svc.CreateAsync(new CreateExpenseRequest { - Type = "VendorPayment", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, - Amount = 50m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 1), - FunctionalClass = "ManagementGeneral", + 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!.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(() => 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] diff --git a/API/ROLAC.API.Tests/Services/Form990ReportServiceTests.cs b/API/ROLAC.API.Tests/Services/Form990ReportServiceTests.cs index 618895e..aa10e1c 100644 --- a/API/ROLAC.API.Tests/Services/Form990ReportServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/Form990ReportServiceTests.cs @@ -36,9 +36,9 @@ public class Form990ReportServiceTests 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), - FunctionalClass = fc, + Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = sub, Amount = amt, FunctionalClass = fc } }, }; [Fact] @@ -82,4 +82,31 @@ public class Form990ReportServiceTests 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 + } } diff --git a/API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs b/API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs index 93a288f..d642442 100644 --- a/API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs @@ -42,8 +42,8 @@ public class MonthlyStatementServiceTests 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 = 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, 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 = "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, 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(); var svc = Build(db); diff --git a/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs b/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs index c4ef865..f4444b9 100644 --- a/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs +++ b/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs @@ -1,26 +1,35 @@ using System.ComponentModel.DataAnnotations; 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 int Id { get; set; } public string Type { 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 int MinistryId { get; set; } public string MinistryName { get; set; } = ""; - public int CategoryGroupId { get; set; } - public string CategoryGroupName { get; set; } = ""; - public int SubCategoryId { get; set; } - public string SubCategoryName { get; set; } = ""; + public int LineCount { get; set; } + public string PrimaryCategoryName { get; set; } = ""; // first line's category (list hint; full breakdown via detail) public string? VendorName { get; set; } public int? MemberId { get; set; } public string? MemberName { get; set; } public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd public bool HasReceipt { get; set; } public string? CheckNumber { get; set; } - public string? FunctionalClass { get; set; } } public class ExpenseDto : ExpenseListItemDto @@ -31,22 +40,29 @@ public class ExpenseDto : ExpenseListItemDto public DateTimeOffset? SubmittedAt { get; set; } public DateTimeOffset? ReviewedAt { get; set; } public DateTimeOffset? PaidAt { get; set; } + public List 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 { [Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement [Required] public int MinistryId { get; set; } - [Required] public int CategoryGroupId { get; set; } - [Required] public int SubCategoryId { get; set; } - [Range(0.01, 9_999_999)] public decimal Amount { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); [Required, MaxLength(500)] public string Description { get; set; } = ""; [MaxLength(200)] public string? VendorName { get; set; } public int? MemberId { get; set; } // ignored for self-service (server uses caller) [MaxLength(50)] public string? CheckNumber { get; set; } [Required] public DateOnly ExpenseDate { get; set; } public string? Notes { get; set; } - [MaxLength(20)] public string? FunctionalClass { get; set; } } public class UpdateExpenseRequest : CreateExpenseRequest { } diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index 738c3f0..360059a 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -22,6 +22,7 @@ public class AppDbContext : IdentityDbContext public DbSet ExpenseSubCategories => Set(); public DbSet Form990ExpenseLines => Set(); public DbSet Expenses => Set(); + public DbSet ExpenseLines => Set(); public DbSet MonthlyStatements => Set(); public DbSet ChurchProfiles => Set(); public DbSet Checks => Set(); @@ -246,7 +247,6 @@ public class AppDbContext : IdentityDbContext entity.Property(e => e.Type).HasMaxLength(30).IsRequired(); 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.Description).HasMaxLength(500).IsRequired(); entity.Property(e => e.VendorName).HasMaxLength(200); @@ -266,12 +266,30 @@ public class AppDbContext : IdentityDbContext entity.HasOne(e => e.Ministry).WithMany() .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(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() .HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict); entity.HasOne(e => e.SubCategory).WithMany() .HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict); - entity.HasOne(e => e.Member).WithMany() - .HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull); }); // ── ChurchProfile (singleton settings) ─────────────────────────────── diff --git a/API/ROLAC.API/Data/MockFinanceData.sql b/API/ROLAC.API/Data/MockFinanceData.sql index b015454..67977f8 100644 --- a/API/ROLAC.API/Data/MockFinanceData.sql +++ b/API/ROLAC.API/Data/MockFinanceData.sql @@ -157,6 +157,8 @@ rows AS ( mi."Id" AS ministry_id, gp."Id" AS group_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.vendor, sp.descr, @@ -172,13 +174,14 @@ rows AS ( JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id" ) +, ins_exp AS ( INSERT INTO "Expenses" - ("MinistryId","CategoryGroupId","SubCategoryId","Type","Status","Amount", + ("Id","MinistryId","Type","Status","Amount", "Description","VendorName","MemberId","CheckNumber","ExpenseDate", "Notes","SubmittedBy","SubmittedAt","ReviewedBy","ReviewedAt","PaidBy","PaidAt", "CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted") 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, r.status, r.amount, @@ -196,6 +199,15 @@ SELECT CASE WHEN r.status = 'Paid' THEN 'mockdata' END, CASE WHEN r.status = 'Paid' THEN r.expense_date::timestamptz END, 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; COMMIT; diff --git a/API/ROLAC.API/Entities/Expense.cs b/API/ROLAC.API/Entities/Expense.cs index 1944aef..3597676 100644 --- a/API/ROLAC.API/Entities/Expense.cs +++ b/API/ROLAC.API/Entities/Expense.cs @@ -5,12 +5,9 @@ public class Expense : SoftDeleteEntity, IAuditable { public int Id { 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 Status { get; set; } = "Draft"; // see state machine - public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass - 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? VendorName { get; set; } public int? MemberId { get; set; } @@ -26,8 +23,7 @@ public class Expense : SoftDeleteEntity, IAuditable public DateTimeOffset? PaidAt { get; set; } public string? PaidBy { get; set; } - public Ministry? Ministry { get; set; } - public ExpenseCategoryGroup? CategoryGroup { get; set; } - public ExpenseSubCategory? SubCategory { get; set; } - public Member? Member { get; set; } + public Ministry? Ministry { get; set; } + public Member? Member { get; set; } + public List Lines { get; set; } = new(); } diff --git a/API/ROLAC.API/Entities/ExpenseLine.cs b/API/ROLAC.API/Entities/ExpenseLine.cs new file mode 100644 index 0000000..b6e2584 --- /dev/null +++ b/API/ROLAC.API/Entities/ExpenseLine.cs @@ -0,0 +1,23 @@ +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// +/// One category line of an . 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). +/// +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; } +} diff --git a/API/ROLAC.API/Entities/FunctionalClasses.cs b/API/ROLAC.API/Entities/FunctionalClasses.cs index 45d8b85..94da724 100644 --- a/API/ROLAC.API/Entities/FunctionalClasses.cs +++ b/API/ROLAC.API/Entities/FunctionalClasses.cs @@ -2,7 +2,7 @@ namespace ROLAC.API.Entities; /// /// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in -/// Ministry.DefaultFunctionalClass and Expense.FunctionalClass. +/// Ministry.DefaultFunctionalClass and ExpenseLine.FunctionalClass. /// public static class FunctionalClasses { diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index d878047..7e63ac1 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -525,9 +525,6 @@ namespace ROLAC.API.Migrations b.Property("Amount") .HasColumnType("decimal(18,2)"); - b.Property("CategoryGroupId") - .HasColumnType("integer"); - b.Property("CheckNumber") .HasMaxLength(50) .HasColumnType("character varying(50)"); @@ -555,10 +552,6 @@ namespace ROLAC.API.Migrations b.Property("ExpenseDate") .HasColumnType("date"); - b.Property("FunctionalClass") - .HasMaxLength(20) - .HasColumnType("character varying(20)"); - b.Property("IsDeleted") .HasColumnType("boolean"); @@ -600,9 +593,6 @@ namespace ROLAC.API.Migrations .HasColumnType("character varying(30)") .HasDefaultValue("Draft"); - b.Property("SubCategoryId") - .HasColumnType("integer"); - b.Property("SubmittedAt") .HasColumnType("timestamp with time zone"); @@ -629,8 +619,6 @@ namespace ROLAC.API.Migrations b.HasKey("Id"); - b.HasIndex("CategoryGroupId"); - b.HasIndex("ExpenseDate"); b.HasIndex("MemberId"); @@ -640,8 +628,6 @@ namespace ROLAC.API.Migrations b.HasIndex("Status") .HasFilter("\"IsDeleted\" = false"); - b.HasIndex("SubCategoryId"); - b.ToTable("Expenses"); }); @@ -694,6 +680,61 @@ namespace ROLAC.API.Migrations b.ToTable("ExpenseCategoryGroups"); }); + modelBuilder.Entity("ROLAC.API.Entities.ExpenseLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryGroupId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpenseId") + .HasColumnType("integer"); + + b.Property("FunctionalClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryGroupId"); + + b.HasIndex("ExpenseId"); + + b.HasIndex("SubCategoryId"); + + b.ToTable("ExpenseLines"); + }); + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => { b.Property("Id") @@ -2007,12 +2048,6 @@ namespace ROLAC.API.Migrations 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") .WithMany() .HasForeignKey("MemberId") @@ -2024,19 +2059,9 @@ namespace ROLAC.API.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); - b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory") - .WithMany() - .HasForeignKey("SubCategoryId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("CategoryGroup"); - b.Navigation("Member"); b.Navigation("Ministry"); - - b.Navigation("SubCategory"); }); modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => @@ -2049,6 +2074,33 @@ namespace ROLAC.API.Migrations 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 => { b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line") @@ -2184,6 +2236,11 @@ namespace ROLAC.API.Migrations b.Navigation("Lines"); }); + modelBuilder.Entity("ROLAC.API.Entities.Expense", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => { b.Navigation("SubCategories"); diff --git a/API/ROLAC.API/Services/DisbursementService.cs b/API/ROLAC.API/Services/DisbursementService.cs index da3ac53..7af4a2c 100644 --- a/API/ROLAC.API/Services/DisbursementService.cs +++ b/API/ROLAC.API/Services/DisbursementService.cs @@ -40,6 +40,19 @@ public class DisbursementService : IDisbursementService 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); + // 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(); foreach (var e in rows) { @@ -77,7 +90,7 @@ public class DisbursementService : IDisbursementService ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), Description = e.Description, Amount = e.Amount, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""), - CategoryName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""), + CategoryName = categoryByExpense.GetValueOrDefault(e.Id, ""), }); g.TotalAmount += e.Amount; } diff --git a/API/ROLAC.API/Services/ExpenseService.cs b/API/ROLAC.API/Services/ExpenseService.cs index 2416453..64afbac 100644 --- a/API/ROLAC.API/Services/ExpenseService.cs +++ b/API/ROLAC.API/Services/ExpenseService.cs @@ -35,8 +35,9 @@ public class ExpenseService : IExpenseService { var query = _db.Expenses.AsNoTracking().AsQueryable(); if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value); - if (categoryGroupId.HasValue) query = query.Where(e => e.CategoryGroupId == categoryGroupId.Value); - if (subCategoryId.HasValue) query = query.Where(e => e.SubCategoryId == subCategoryId.Value); + // Category filters now match against any line of the expense. + 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 // request the Paid+Approved set in one call. 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 grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}"); - var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => $"{s.Name_en} / {s.Name_zh}"); var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet(); var memNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)) .ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}"); - 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, - MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""), - CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""), - SubCategoryId = e.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(e.SubCategoryId, ""), - VendorName = e.VendorName, MemberId = e.MemberId, - MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null, - ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), - HasReceipt = e.ReceiptBlobPath != null, - CheckNumber = e.CheckNumber, - FunctionalClass = e.FunctionalClass, + 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, + MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""), + LineCount = ls?.Count ?? 0, + PrimaryCategoryName = grpNames.GetValueOrDefault(firstGroupId, ""), + VendorName = e.VendorName, MemberId = e.MemberId, + 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(); return new PagedResult { 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); if (e is null) return null; 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 ? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync() : 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 { Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description, MinistryId = e.MinistryId, MinistryName = minName, - CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpName, - SubCategoryId = e.SubCategoryId, SubCategoryName = subName, + LineCount = lineDtos.Count, + PrimaryCategoryName = lineDtos.Count > 0 ? lineDtos[0].CategoryGroupName : "", VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null, CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes, 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 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 BuildLines(List 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 CreateAsync(CreateExpenseRequest r, bool isFinance) { + ValidateLines(r.Lines); var e = new Expense { - MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId, - Type = r.Type, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName, + MinistryId = r.MinistryId, + 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, - FunctionalClass = r.FunctionalClass, + Lines = BuildLines(r.Lines), }; if (r.Type == "VendorPayment") @@ -174,16 +221,21 @@ public class ExpenseService : IExpenseService public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance) { + ValidateLines(r.Lines); // 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."); if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval"))) 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.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber; - e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.FunctionalClass = r.FunctionalClass; + e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber; + e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; 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(); } diff --git a/API/ROLAC.API/Services/FinanceDashboardService.cs b/API/ROLAC.API/Services/FinanceDashboardService.cs index db541dd..029bdd3 100644 --- a/API/ROLAC.API/Services/FinanceDashboardService.cs +++ b/API/ROLAC.API/Services/FinanceDashboardService.cs @@ -53,17 +53,24 @@ public class FinanceDashboardService : IFinanceDashboardService DateOnly? from, DateOnly? to, int? ministryId, int? categoryGroupId) { var q = PaidApproved(from, to); - if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value); - if (categoryGroupId.HasValue) q = q.Where(e => e.CategoryGroupId == categoryGroupId.Value); + if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.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; 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(x => (x.Id, x.Amount)).ToList(); 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(x => (x.Id, x.Amount)).ToList(); else diff --git a/API/ROLAC.API/Services/Form990ReportService.cs b/API/ROLAC.API/Services/Form990ReportService.cs index d43e7eb..054a0ab 100644 --- a/API/ROLAC.API/Services/Form990ReportService.cs +++ b/API/ROLAC.API/Services/Form990ReportService.cs @@ -8,7 +8,7 @@ namespace ROLAC.API.Services; /// /// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional /// 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. /// public class Form990ReportService : IForm990ReportService { @@ -40,13 +40,14 @@ public class Form990ReportService : IForm990ReportService 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 e.SubCategoryId equals sub.Id - join grp in _db.ExpenseCategoryGroups on e.CategoryGroupId equals grp.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 { - e.Amount, - e.FunctionalClass, + l.Amount, + l.FunctionalClass, MinistryDefault = m.DefaultFunctionalClass, SubLineId = sub.Form990LineId, GroupLineId = grp.Form990LineId, diff --git a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html index d5302fc..fb5ccde 100644 --- a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html +++ b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html @@ -1,128 +1,147 @@ - -
+ + +
- - +
- - - - - - - + + +
+ + + +
+
+ 收據預覽 / Receipt + +
+ + {{ receiptZoom * 100 | number:'1.0-0' }}% + + +
+
+ + +
+ Receipt preview +
+ + +
+ -
+ \ No newline at end of file diff --git a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts index 33e295f..91ad999 100644 --- a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts +++ b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts @@ -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 { FormsModule } from '@angular/forms'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { InputsModule } from '@progress/kendo-angular-inputs'; import { ButtonsModule } from '@progress/kendo-angular-buttons'; 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 { MinistryApiService } from '../../services/ministry-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 { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model'; import { MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest, - ExpenseListItemDto, FunctionalClass, + ExpenseDto, FunctionalClass, } from '../../models/expense.model'; export interface ExpenseFormResult { @@ -25,18 +27,30 @@ export interface ExpenseFormResult { /** Flattened member item with a single displayName field for the dropdown. */ 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({ selector: 'app-expense-form-dialog', standalone: true, imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule, DropDownsModule, DateInputsModule], templateUrl: './expense-form-dialog.component.html', }) -export class ExpenseFormDialogComponent implements OnInit { +export class ExpenseFormDialogComponent implements OnInit, OnDestroy { @Input() mode: 'vendor' | 'reimbursement' = 'reimbursement'; @Input() allowMemberPick = false; @Input() title = 'New Expense'; - /** When set, the dialog prefills from this row for editing instead of starting blank. */ - @Input() expense: ExpenseListItemDto | null = null; + /** When set, the dialog prefills from this expense (with its lines) for editing. */ + @Input() expense: ExpenseDto | null = null; @Output() save = new EventEmitter(); @Output() cancel = new EventEmitter(); @@ -45,19 +59,12 @@ export class ExpenseFormDialogComponent implements OnInit { ministries: MinistryDto[] = []; groups: ExpenseCategoryGroupDto[] = []; - subs: ExpenseSubCategoryDto[] = []; memberResults: MemberOption[] = []; /** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */ 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. */ get showContinueEntry(): boolean { return this.mode === 'reimbursement' && this.allowMemberPick && !this.expense; @@ -65,56 +72,108 @@ export class ExpenseFormDialogComponent implements OnInit { form = { ministryId: null as number | null, - categoryGroupId: null as number | null, - subCategoryId: null as number | null, - amount: 0, description: '', vendorName: '', checkNumber: '', memberId: null as number | null, 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 preview (right panel) ──────────────────────────────────────── + /** Blob URL for an image receipt, bound directly to . */ + receiptImageUrl: string | null = null; + /** Sanitized blob URL for a PDF receipt, bound to