@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<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
|
||||
{
|
||||
[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<ExpenseLineInput> 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 { }
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
||||
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
|
||||
public DbSet<Expense> Expenses => Set<Expense>();
|
||||
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
|
||||
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
||||
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
||||
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.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<AppUser, AppRole, string>
|
||||
|
||||
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<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()
|
||||
.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) ───────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ExpenseLine> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -2,7 +2,7 @@ namespace ROLAC.API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in
|
||||
/// Ministry.DefaultFunctionalClass and Expense.FunctionalClass.
|
||||
/// Ministry.DefaultFunctionalClass and ExpenseLine.FunctionalClass.
|
||||
/// </summary>
|
||||
public static class FunctionalClasses
|
||||
{
|
||||
|
||||
@@ -525,9 +525,6 @@ namespace ROLAC.API.Migrations
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("CategoryGroupId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("CheckNumber")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
@@ -555,10 +552,6 @@ namespace ROLAC.API.Migrations
|
||||
b.Property<DateOnly>("ExpenseDate")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("FunctionalClass")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -600,9 +593,6 @@ namespace ROLAC.API.Migrations
|
||||
.HasColumnType("character varying(30)")
|
||||
.HasDefaultValue("Draft");
|
||||
|
||||
b.Property<int>("SubCategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("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<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 =>
|
||||
{
|
||||
b.Property<int>("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");
|
||||
|
||||
@@ -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<string, PayeeGroupDto>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<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);
|
||||
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<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)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ROLAC.API.Services;
|
||||
/// <summary>
|
||||
/// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional
|
||||
/// Expenses. Expense scope matches FinanceDashboardService: Paid + Approved only.
|
||||
/// Single function per expense (direct-charge); no cost splitting.
|
||||
/// Each expense line is categorized independently, so one invoice can span multiple lines.
|
||||
/// </summary>
|
||||
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,
|
||||
|
||||
+129
-110
@@ -1,128 +1,147 @@
|
||||
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="showReceiptPanel ? 1200 : 760" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||
<!-- 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) -->
|
||||
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
||||
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
|
||||
<span>連續登打 / Continuous Entry</span>
|
||||
</label>
|
||||
<div class="flex-1 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||
|
||||
<!-- 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"
|
||||
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
|
||||
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
||||
<kendo-switch [(ngModel)]="continueEntry"></kendo-switch>
|
||||
<span>連續登打 / Continuous Entry</span>
|
||||
</label>
|
||||
|
||||
<!-- Description -->
|
||||
<label class="flex flex-col gap-1 md:col-span-2">Description
|
||||
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
|
||||
</label>
|
||||
<!-- Member picker (finance creating on behalf of a member) -->
|
||||
<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">
|
||||
</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>
|
||||
</kendo-dropdownlist>
|
||||
</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)
|
||||
<!--
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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)
|
||||
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.
|
||||
-->
|
||||
<input
|
||||
#receiptInput
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
(change)="onFileSelected($event)"
|
||||
<input #receiptInput type="file" accept="image/*,application/pdf" (change)="onFileSelected($event)"
|
||||
(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" />
|
||||
</label>
|
||||
</ng-container>
|
||||
</label>
|
||||
</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>
|
||||
<!-- /two-column body -->
|
||||
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="cancel.emit()">Cancel</button>
|
||||
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
</kendo-dialog>
|
||||
+124
-40
@@ -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<ExpenseFormResult>();
|
||||
@Output() cancel = new EventEmitter<void>();
|
||||
|
||||
@@ -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 <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(
|
||||
private ministryApi: MinistryApiService,
|
||||
private catApi: ExpenseCategoryApiService,
|
||||
private memberApi: MemberApiService,
|
||||
private expenseApi: ExpenseApiService,
|
||||
private sanitizer: DomSanitizer,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
||||
this.catApi.getAll(false).subscribe(groups => {
|
||||
this.groups = groups;
|
||||
// Populate the sub-category list for the prefilled group so its value displays on edit.
|
||||
if (this.expense) {
|
||||
this.subs = this.groups.find(group => group.id === this.expense!.categoryGroupId)?.subCategories ?? [];
|
||||
}
|
||||
// Populate each line's sub-category list once the catalog is loaded (edit mode).
|
||||
if (this.expense) this.hydrateLineSubs();
|
||||
});
|
||||
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.
|
||||
const [year, month, day] = expense.expenseDate.split('-').map(Number);
|
||||
this.form = {
|
||||
ministryId: expense.ministryId,
|
||||
categoryGroupId: expense.categoryGroupId,
|
||||
subCategoryId: expense.subCategoryId,
|
||||
amount: expense.amount,
|
||||
description: expense.description,
|
||||
vendorName: expense.vendorName ?? '',
|
||||
checkNumber: expense.checkNumber ?? '',
|
||||
memberId: expense.memberId,
|
||||
expenseDate: new Date(year, month - 1, day),
|
||||
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 {
|
||||
this.form.subCategoryId = null;
|
||||
this.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
|
||||
/** Fill each line's sub-category list from its chosen group (used after the catalog loads on edit). */
|
||||
private hydrateLineSubs(): void {
|
||||
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 {
|
||||
@@ -130,12 +189,34 @@ export class ExpenseFormDialogComponent implements OnInit {
|
||||
|
||||
onFileSelected(event: Event): void {
|
||||
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 {
|
||||
return !!this.form.ministryId && !!this.form.categoryGroupId && !!this.form.subCategoryId
|
||||
&& this.form.amount > 0 && this.form.description.trim().length > 0;
|
||||
return !!this.form.ministryId
|
||||
&& this.form.description.trim().length > 0
|
||||
&& this.lines.length > 0
|
||||
&& this.lines.every(l => !!l.categoryGroupId && !!l.subCategoryId && l.amount > 0);
|
||||
}
|
||||
|
||||
emitSave(): void {
|
||||
@@ -145,16 +226,19 @@ export class ExpenseFormDialogComponent implements OnInit {
|
||||
const request: CreateExpenseRequest = {
|
||||
type: (this.mode === 'vendor' ? 'VendorPayment' : 'StaffReimbursement') as ExpenseType,
|
||||
ministryId: this.form.ministryId!,
|
||||
categoryGroupId: this.form.categoryGroupId!,
|
||||
subCategoryId: this.form.subCategoryId!,
|
||||
amount: this.form.amount,
|
||||
lines: this.lines.map(l => ({
|
||||
categoryGroupId: l.categoryGroupId!,
|
||||
subCategoryId: l.subCategoryId!,
|
||||
amount: l.amount,
|
||||
functionalClass: l.functionalClass,
|
||||
description: l.description.trim() || null,
|
||||
})),
|
||||
description: this.form.description.trim(),
|
||||
vendorName: this.mode === 'vendor' ? (this.form.vendorName || null) : null,
|
||||
memberId: this.allowMemberPick ? this.form.memberId : null,
|
||||
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
|
||||
expenseDate,
|
||||
notes: null,
|
||||
functionalClass: this.form.functionalClass,
|
||||
};
|
||||
// The request and receipt are snapshotted here, so resetting the form right
|
||||
// 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,
|
||||
* Sub-Category and Expense Date (plus the loaded sub-category list) so the
|
||||
* user can immediately log the next reimbursement.
|
||||
* Clear only the per-entry fields, keeping Member, Ministry and Expense Date so the
|
||||
* user can immediately log the next reimbursement. Lines reset to a single blank row.
|
||||
*/
|
||||
private resetForNext(): void {
|
||||
this.form.amount = 0;
|
||||
this.lines = [this.emptyLine()];
|
||||
this.form.description = '';
|
||||
this.receipt = null;
|
||||
this.clearPreview();
|
||||
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 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 {
|
||||
id: number; type: ExpenseType; status: ExpenseStatus; amount: number; description: string;
|
||||
ministryId: number; ministryName: string; categoryGroupId: number; categoryGroupName: string;
|
||||
subCategoryId: number; subCategoryName: string; vendorName: string | null;
|
||||
ministryId: number; ministryName: string; lineCount: number; primaryCategoryName: string;
|
||||
vendorName: string | null;
|
||||
memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean;
|
||||
checkNumber: string | null; functionalClass: FunctionalClass | null;
|
||||
checkNumber: string | null;
|
||||
}
|
||||
export interface ExpenseDto extends ExpenseListItemDto {
|
||||
notes: string | null; reviewNotes: 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 {
|
||||
type: ExpenseType; ministryId: number; categoryGroupId: number; subCategoryId: number;
|
||||
amount: number; description: string; vendorName: string | null; memberId: number | null;
|
||||
checkNumber: string | null; expenseDate: string; notes: string | null; functionalClass: FunctionalClass | null;
|
||||
type: ExpenseType; ministryId: number; lines: ExpenseLineInput[];
|
||||
description: string; vendorName: string | null; memberId: number | null;
|
||||
checkNumber: string | null; expenseDate: string; notes: string | null;
|
||||
}
|
||||
export type UpdateExpenseRequest = CreateExpenseRequest;
|
||||
export interface RejectExpenseRequest { reviewNotes: string | null; }
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
<kendo-grid-column title="Category" [width]="360">
|
||||
<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>
|
||||
</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 { MinistryApiService } from '../../services/ministry-api.service';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
@@ -39,7 +39,7 @@ export class ExpensesPageComponent implements OnInit {
|
||||
vendorDialogOpen = false;
|
||||
reimbDialogOpen = false;
|
||||
|
||||
editRow: ExpenseListItemDto | null = null;
|
||||
editRow: ExpenseDto | null = null;
|
||||
editMode: 'vendor' | 'reimbursement' = 'reimbursement';
|
||||
|
||||
payRow: ExpenseListItemDto | null = null;
|
||||
@@ -95,8 +95,9 @@ export class ExpensesPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
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.api.getById(row.id).subscribe(dto => (this.editRow = dto));
|
||||
}
|
||||
|
||||
closeEdit(): void { this.editRow = null; }
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@
|
||||
<kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column>
|
||||
<kendo-grid-column title="Category">
|
||||
<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>
|
||||
</kendo-grid-column>
|
||||
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<dt>Category</dt>
|
||||
<dd>{{ row.categoryGroupName }} / {{ row.subCategoryName }}</dd>
|
||||
<dd>{{ row.primaryCategoryName }}<span *ngIf="row.lineCount > 1"> +{{ row.lineCount - 1 }}</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
|
||||
+6
-3
@@ -4,7 +4,7 @@ import { GridModule } from '@progress/kendo-angular-grid';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { ExpenseApiService } from '../../services/expense-api.service';
|
||||
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 { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
|
||||
|
||||
@@ -19,7 +19,7 @@ export class MyReimbursementsPageComponent implements OnInit {
|
||||
rows: ExpenseListItemDto[] = [];
|
||||
loading = false;
|
||||
dialogOpen = false;
|
||||
editRow: ExpenseListItemDto | null = null;
|
||||
editRow: ExpenseDto | null = null;
|
||||
|
||||
constructor(private api: ExpenseApiService) {}
|
||||
|
||||
@@ -34,7 +34,10 @@ export class MyReimbursementsPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
onSave(result: ExpenseFormResult): void {
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@
|
||||
<ng-template kendoGridCellTemplate let-d>
|
||||
<div class="cell-item">
|
||||
<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>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
Reference in New Issue
Block a user