diff --git a/API/ROLAC.API/Controllers/ExpensesController.cs b/API/ROLAC.API/Controllers/ExpensesController.cs index b0107c6..28ffde6 100644 --- a/API/ROLAC.API/Controllers/ExpensesController.cs +++ b/API/ROLAC.API/Controllers/ExpensesController.cs @@ -25,10 +25,11 @@ public class ExpensesController : ControllerBase public async Task GetPaged( [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null, [FromQuery] int? ministryId = null, [FromQuery] int? categoryGroupId = null, - [FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) + [FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null, + [FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null) { if (!CanViewAll()) return Forbid(); - return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to)); + return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses)); } [HttpGet("mine")] diff --git a/API/ROLAC.API/Controllers/FinanceDashboardController.cs b/API/ROLAC.API/Controllers/FinanceDashboardController.cs new file mode 100644 index 0000000..ac174e1 --- /dev/null +++ b/API/ROLAC.API/Controllers/FinanceDashboardController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/finance-dashboard")] +[Authorize(Roles = "finance,super_admin")] +public class FinanceDashboardController : ControllerBase +{ + private readonly IFinanceDashboardService _svc; + public FinanceDashboardController(IFinanceDashboardService svc) => _svc = svc; + + [HttpGet("summary")] + public async Task Summary() + => Ok(await _svc.GetSummaryAsync()); + + [HttpGet("income-expense")] + public async Task IncomeExpense([FromQuery] DateOnly? from, [FromQuery] DateOnly? to) + => Ok(await _svc.GetIncomeExpenseAsync(from, to)); + + [HttpGet("expense-breakdown")] + public async Task ExpenseBreakdown( + [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, + [FromQuery] int? ministryId, [FromQuery] int? categoryGroupId) + => Ok(await _svc.GetExpenseBreakdownAsync(from, to, ministryId, categoryGroupId)); +} diff --git a/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs b/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs index f816e42..ec1f434 100644 --- a/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs +++ b/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs @@ -19,11 +19,11 @@ public class ExpenseListItemDto public string? MemberName { get; set; } public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd public bool HasReceipt { get; set; } + public string? CheckNumber { get; set; } } public class ExpenseDto : ExpenseListItemDto { - public string? CheckNumber { get; set; } public string? Notes { get; set; } public string? ReviewNotes { get; set; } public string? SubmittedBy { get; set; } diff --git a/API/ROLAC.API/DTOs/Finance/FinanceDashboardDtos.cs b/API/ROLAC.API/DTOs/Finance/FinanceDashboardDtos.cs new file mode 100644 index 0000000..673f9fe --- /dev/null +++ b/API/ROLAC.API/DTOs/Finance/FinanceDashboardDtos.cs @@ -0,0 +1,25 @@ +namespace ROLAC.API.DTOs.Finance; + +/// All-time finance position for the dashboard balance card. +public class FinanceSummaryDto +{ + public decimal TotalIncome { get; set; } // all-time sum of Giving.Amount + public decimal TotalExpenses { get; set; } // all-time Paid+Approved expenses + public decimal Balance { get; set; } // TotalIncome - TotalExpenses +} + +/// Income vs expense totals for a date range (the income/expense pie). +public class IncomeExpenseDto +{ + public decimal Income { get; set; } // Givings in [from,to] + public decimal Expense { get; set; } // Paid+Approved expenses in [from,to] +} + +/// One slice of the expense drill-down pie. Id is a ministry / group / sub-category id by level. +public class BreakdownSliceDto +{ + public int Id { get; set; } + public string Name_en { get; set; } = ""; + public string? Name_zh { get; set; } + public decimal Amount { get; set; } +} diff --git a/API/ROLAC.API/Data/MockFinanceData.sql b/API/ROLAC.API/Data/MockFinanceData.sql new file mode 100644 index 0000000..b015454 --- /dev/null +++ b/API/ROLAC.API/Data/MockFinanceData.sql @@ -0,0 +1,217 @@ +-- ============================================================================ +-- ROLAC — Finance mock data (奉獻 / 支出) — ONE-OFF dev seed script +-- ============================================================================ +-- Populates ~12 months of Givings (offering sessions) and Expenses so the +-- Finance Dashboard has trends, pie charts, and breakdowns to render. +-- +-- WHAT IT CREATES +-- • ~8 mock Members (mixed EN/ZH names) — so some givings link to a person. +-- • 52 weekly OfferingSessions (one per Sunday, last 12 months), each with +-- ~8 Giving lines across all giving categories + payment methods. About +-- half the lines are linked to a member, the rest are anonymous. +-- • ~12 months of Expenses across every ministry / category group, mostly +-- Paid/Approved (so they count in the dashboard) with a few Submitted. +-- +-- ALL rows are stamped CreatedBy = 'mockdata'. Re-running this script first +-- DELETEs the previous mock rows, so it is SAFE TO RUN MANY TIMES. +-- +-- PREREQUISITE: reference data must already be seeded (run the API once so +-- DbSeeder fills GivingCategories / Ministries / ExpenseCategoryGroups / +-- ExpenseSubCategories). This script looks those up by Name_en. +-- +-- HOW TO RUN (dev DB = PostgreSQL "ChurchCRM" on 192.168.68.55:49154): +-- psql "Host=192.168.68.55;Port=49154;Database=ChurchCRM;Username=chris;Password=1124" -f MockFinanceData.sql +-- …or just paste it into pgAdmin / DBeaver against the ChurchCRM database. +-- ============================================================================ + +BEGIN; + +-- --------------------------------------------------------------------------- +-- 0. Clean up any previous mock data (order respects FKs) +-- --------------------------------------------------------------------------- +DELETE FROM "Givings" WHERE "CreatedBy" = 'mockdata'; +DELETE FROM "OfferingSessions" WHERE "CreatedBy" = 'mockdata'; +DELETE FROM "Expenses" WHERE "CreatedBy" = 'mockdata'; +DELETE FROM "Members" WHERE "CreatedBy" = 'mockdata'; + +-- --------------------------------------------------------------------------- +-- 1. Mock members (mixed EN/ZH) — some givings & reimbursements link to these +-- --------------------------------------------------------------------------- +INSERT INTO "Members" + ("FirstName_en","LastName_en","FirstName_zh","LastName_zh","Gender","Email", + "Status","LanguagePreference","Country", + "CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted") +VALUES + ('Wei', 'Chen', '偉', '陳', 'M', 'mock.wei@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false), + ('Mei', 'Lin', '美', '林', 'F', 'mock.mei@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false), + ('David', 'Wang', '大衛', '王', 'M', 'mock.david@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false), + ('Grace', 'Liu', '恩典', '劉', 'F', 'mock.grace@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false), + ('Samuel', 'Huang', '撒母耳','黃','M', 'mock.samuel@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false), + ('Esther', 'Wu', '以斯帖','吳','F', 'mock.esther@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false), + ('Joshua', 'Tsai', '約書亞','蔡','M', 'mock.joshua@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false), + ('Hannah', 'Hsu', '漢娜', '許', 'F', 'mock.hannah@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false); + +-- --------------------------------------------------------------------------- +-- 2. Weekly offering sessions — last 52 Sundays +-- --------------------------------------------------------------------------- +-- last_sunday = the most recent Sunday on/before today (dow: Sunday = 0) +INSERT INTO "OfferingSessions" + ("SessionDate","Status","CashTotal","CheckTotal","SystemTotal","Difference", + "CreatedAt","CreatedBy","UpdatedAt","UpdatedBy") +SELECT w.d, 'Reconciled', 0, 0, 0, 0, + w.d::timestamptz, 'mockdata', w.d::timestamptz, 'mockdata' +FROM ( + SELECT ((current_date - (extract(dow from current_date))::int) - (g * 7)) AS d + FROM generate_series(0, 51) AS g +) w +WHERE NOT EXISTS ( -- skip dates that already have a session + SELECT 1 FROM "OfferingSessions" os WHERE os."SessionDate" = w.d +); + +-- --------------------------------------------------------------------------- +-- 3. Giving lines — ~8 per mock session, spread over categories & methods +-- --------------------------------------------------------------------------- +-- Line "specs": n, category (by Name_en), payment method, link-to-member?, +-- amount floor, amount range. +WITH specs(n, cat_name, method, link, amin, arange) AS ( + VALUES + (1, 'Tithe', 'Cash', true, 100, 900), + (2, 'Tithe', 'Check', true, 100, 900), + (3, 'Tithe', 'Zelle', false, 80, 600), + (4, 'General Offering', 'Cash', false, 20, 180), + (5, 'General Offering', 'PayPal', true, 20, 180), + (6, 'Special Offering', 'Check', true, 50, 450), + (7, 'Building Fund', 'Zelle', true, 100, 900), + (8, 'Mission', 'Cash', false, 30, 270) +) +INSERT INTO "Givings" + ("MemberId","GivingCategoryId","OfferingSessionId","Amount","PaymentMethod", + "CheckNumber","ZelleReferenceCode","PayPalTransactionId","GivingDate", + "IsAnonymous","Notes","CreatedAt","CreatedBy","UpdatedAt","UpdatedBy") +SELECT + CASE WHEN sp.link + THEN (SELECT m."Id" FROM "Members" m WHERE m."IsDeleted" = false ORDER BY random() LIMIT 1) + END, + gc."Id", + s."Id", + round((sp.amin + random() * sp.arange))::numeric(18,2), + sp.method, + CASE WHEN sp.method = 'Check' THEN (1000 + (random() * 8999)::int)::text END, + CASE WHEN sp.method = 'Zelle' THEN 'ZL' || to_char(s."SessionDate",'YYYYMMDD') || sp.n END, + CASE WHEN sp.method = 'PayPal' THEN 'PP-' || substr(md5(random()::text), 1, 10) END, + s."SessionDate", + (NOT sp.link), -- anonymous when not linked to a member + NULL, + s."SessionDate"::timestamptz, 'mockdata', s."SessionDate"::timestamptz, 'mockdata' +FROM "OfferingSessions" s +CROSS JOIN specs sp +JOIN "GivingCategories" gc ON gc."Name_en" = sp.cat_name +WHERE s."CreatedBy" = 'mockdata'; + +-- Roll the giving lines up into each session's Cash / Check / System totals. +UPDATE "OfferingSessions" os SET + "CashTotal" = COALESCE(sub.cash, 0), + "CheckTotal" = COALESCE(sub.chk, 0), + "SystemTotal" = COALESCE(sub.sys, 0), + "Difference" = 0 +FROM ( + SELECT "OfferingSessionId" sid, + SUM("Amount") FILTER (WHERE "PaymentMethod" = 'Cash') AS cash, + SUM("Amount") FILTER (WHERE "PaymentMethod" = 'Check') AS chk, + SUM("Amount") AS sys + FROM "Givings" + WHERE "CreatedBy" = 'mockdata' + GROUP BY "OfferingSessionId" +) sub +WHERE os."Id" = sub.sid; + +-- --------------------------------------------------------------------------- +-- 4. Expenses — recurring monthly spend across ministries & category groups +-- --------------------------------------------------------------------------- +-- Spec: ministry, category group, sub-category (all by Name_en), +-- is_reimbursement?, vendor name, description, amount floor, range. +WITH specs(ministry, grp, sub, is_reimb, vendor, descr, amin, arange) AS ( + VALUES + ('Facility', 'Facility', 'Rent', false, 'Arcadia Property Mgmt', 'Monthly facility rent / 場地月租', 2000, 400), + ('Facility', 'Facility', 'Utilities', false, 'SoCal Edison', 'Electricity & water / 水電費', 250, 250), + ('Worship', 'Equipment', 'Maintenance & Repair', false, 'Guitar Center Service', 'Instrument/sound maintenance / 樂器維修', 80, 220), + ('Sound', 'Equipment', 'Rental', false, 'AV Rentals LA', 'AV equipment rental / 影音設備租借', 150, 350), + ('PPT/Media', 'Consumables', 'Accessories', false, 'B&H Photo', 'Cables & adapters / 線材配件', 40, 160), + ('Catering', 'Food & Beverage','Catering', false, '85C Bakery', 'Sunday fellowship meal / 主日愛筵', 200, 300), + ('Catering', 'Food & Beverage','Food Ingredients', true, NULL, 'Groceries for kitchen / 廚房食材', 80, 220), + ('Children', 'Materials', 'Craft Supplies', true, NULL, 'Sunday school crafts / 兒主手工材料', 40, 160), + ('Children', 'Materials', 'Printing', false, 'FedEx Office', 'Children lesson printing / 兒童教材印刷', 50, 150), + ('Administration','Printing', 'Bulletins', false, 'FedEx Office', 'Weekly bulletin printing / 週報印刷', 60, 120), + ('Administration','Consumables', 'Office Supplies', true, NULL, 'Office supplies / 辦公文具', 30, 120), + ('Hospitality', 'Consumables', 'Cleaning Supplies', true, NULL, 'Cleaning supplies / 清潔用品', 30, 90), + ('Preaching', 'Personnel', 'Honorarium', true, NULL, 'Guest speaker honorarium / 講員酬庸', 100, 300), + ('Administration','Missions', 'Missionary Support', false, 'OMF International', 'Monthly missionary support / 宣教士月支援', 200, 300) +), +-- 12 months back from the current month +months AS ( + SELECT (date_trunc('month', current_date) - (g || ' month')::interval)::date AS m0 + FROM generate_series(0, 11) AS g +), +rows AS ( + SELECT + mi."Id" AS ministry_id, + gp."Id" AS group_id, + sc."Id" AS sub_id, + sp.is_reimb, + sp.vendor, + sp.descr, + -- expense date: a day within that month, never in the future + LEAST(mo.m0 + (random() * 27)::int, current_date) AS expense_date, + round((sp.amin + random() * sp.arange))::numeric(18,2) AS amount, + -- weighted status: 7 Paid / 2 Approved / 1 Submitted + (ARRAY['Paid','Paid','Paid','Paid','Paid','Paid','Paid','Approved','Approved','Submitted']) + [1 + (random() * 9)::int] AS status + FROM specs sp + CROSS JOIN months mo + JOIN "Ministries" mi ON mi."Name_en" = sp.ministry + JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp + JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id" +) +INSERT INTO "Expenses" + ("MinistryId","CategoryGroupId","SubCategoryId","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, + CASE WHEN r.is_reimb THEN 'StaffReimbursement' ELSE 'VendorPayment' END, + r.status, + r.amount, + r.descr, + CASE WHEN r.is_reimb THEN NULL ELSE r.vendor END, + CASE WHEN r.is_reimb + THEN (SELECT m."Id" FROM "Members" m WHERE m."IsDeleted" = false ORDER BY random() LIMIT 1) + END, + CASE WHEN NOT r.is_reimb AND r.status = 'Paid' THEN (4000 + (random() * 5999)::int)::text END, + r.expense_date, + NULL, + 'mockdata', r.expense_date::timestamptz, + CASE WHEN r.status IN ('Approved','Paid') THEN 'mockdata' END, + CASE WHEN r.status IN ('Approved','Paid') THEN r.expense_date::timestamptz END, + 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; + +COMMIT; + +-- --------------------------------------------------------------------------- +-- Quick verification (run after commit) +-- --------------------------------------------------------------------------- +SELECT 'members' AS kind, count(*)::text AS value FROM "Members" WHERE "CreatedBy" = 'mockdata' +UNION ALL +SELECT 'sessions', count(*)::text FROM "OfferingSessions" WHERE "CreatedBy" = 'mockdata' +UNION ALL +SELECT 'givings', count(*)::text FROM "Givings" WHERE "CreatedBy" = 'mockdata' +UNION ALL +SELECT 'giving $', COALESCE(sum("Amount"), 0)::numeric(18,2)::text FROM "Givings" WHERE "CreatedBy" = 'mockdata' +UNION ALL +SELECT 'expenses', count(*)::text FROM "Expenses" WHERE "CreatedBy" = 'mockdata' +UNION ALL +SELECT 'expense $ (paid+appr)', COALESCE(sum("Amount"), 0)::numeric(18,2)::text + FROM "Expenses" WHERE "CreatedBy" = 'mockdata' AND "Status" IN ('Paid','Approved'); diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 1daf205..fe8dfa3 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -127,6 +127,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // --------------------------------------------------------------------------- // Swagger / MVC diff --git a/API/ROLAC.API/Services/ExpenseService.cs b/API/ROLAC.API/Services/ExpenseService.cs index 769e8e4..76a6d9c 100644 --- a/API/ROLAC.API/Services/ExpenseService.cs +++ b/API/ROLAC.API/Services/ExpenseService.cs @@ -27,12 +27,24 @@ public class ExpenseService : IExpenseService public async Task> GetPagedAsync( int page, int pageSize, string? search, int? ministryId, - int? categoryGroupId, string? status, DateOnly? from, DateOnly? to) + int? categoryGroupId, string? status, DateOnly? from, DateOnly? to, + int? subCategoryId = null, string? statuses = null) { 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 (!string.IsNullOrWhiteSpace(status)) query = query.Where(e => e.Status == status); + if (subCategoryId.HasValue) query = query.Where(e => e.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)) + { + var set = statuses.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + query = query.Where(e => set.Contains(e.Status)); + } + else if (!string.IsNullOrWhiteSpace(status)) + { + query = query.Where(e => e.Status == status); + } if (from.HasValue) query = query.Where(e => e.ExpenseDate >= from.Value); if (to.HasValue) query = query.Where(e => e.ExpenseDate <= to.Value); if (!string.IsNullOrWhiteSpace(search)) @@ -41,6 +53,7 @@ public class ExpenseService : IExpenseService query = query.Where(e => e.Description.ToLower().Contains(s) || (e.VendorName != null && e.VendorName.ToLower().Contains(s)) || + (e.CheckNumber != null && e.CheckNumber.ToLower().Contains(s)) || (e.Member != null && ( (e.Member.FirstName_en + " " + e.Member.LastName_en).ToLower().Contains(s) || (e.Member.FirstName_zh != null && e.Member.FirstName_zh.Contains(term)) || @@ -80,6 +93,7 @@ public class ExpenseService : IExpenseService 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 }; diff --git a/API/ROLAC.API/Services/FinanceDashboardService.cs b/API/ROLAC.API/Services/FinanceDashboardService.cs new file mode 100644 index 0000000..db541dd --- /dev/null +++ b/API/ROLAC.API/Services/FinanceDashboardService.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Finance; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +/// +/// Read-only aggregation over Givings + Expenses for the Finance Dashboard. +/// Expense scope is Paid+Approved everywhere (decided with the user); income is Givings only. +/// +public class FinanceDashboardService : IFinanceDashboardService +{ + private readonly AppDbContext _db; + public FinanceDashboardService(AppDbContext db) => _db = db; + + // Paid+Approved expenses, optionally bounded by ExpenseDate. Shared by the + // income/expense pie and the breakdown so the filter is identical in both. + private IQueryable PaidApproved(DateOnly? from, DateOnly? to) + { + var q = _db.Expenses.Where(e => e.Status == "Paid" || e.Status == "Approved"); + if (from.HasValue) q = q.Where(e => e.ExpenseDate >= from.Value); + if (to.HasValue) q = q.Where(e => e.ExpenseDate <= to.Value); + return q; + } + + public async Task GetSummaryAsync() + { + var income = await _db.Givings.SumAsync(g => (decimal?)g.Amount) ?? 0m; + var expense = await PaidApproved(null, null).SumAsync(e => (decimal?)e.Amount) ?? 0m; + return new FinanceSummaryDto + { + TotalIncome = income, + TotalExpenses = expense, + Balance = income - expense, + }; + } + + public async Task GetIncomeExpenseAsync(DateOnly? from, DateOnly? to) + { + var giving = _db.Givings.AsQueryable(); + if (from.HasValue) giving = giving.Where(g => g.GivingDate >= from.Value); + if (to.HasValue) giving = giving.Where(g => g.GivingDate <= to.Value); + + return new IncomeExpenseDto + { + Income = await giving.SumAsync(g => (decimal?)g.Amount) ?? 0m, + Expense = await PaidApproved(from, to).SumAsync(e => (decimal?)e.Amount) ?? 0m, + }; + } + + public async Task> GetExpenseBreakdownAsync( + 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); + + // Group by the deepest level whose parent id is supplied. + List<(int Id, decimal Amount)> grouped; + if (categoryGroupId.HasValue) + grouped = (await q.GroupBy(e => e.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) + .Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync()) + .Select(x => (x.Id, x.Amount)).ToList(); + else + grouped = (await q.GroupBy(e => e.MinistryId) + .Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync()) + .Select(x => (x.Id, x.Amount)).ToList(); + + var ids = grouped.Select(x => x.Id).ToHashSet(); + Dictionary names = categoryGroupId.HasValue + ? await _db.ExpenseSubCategories.Where(s => ids.Contains(s.Id)) + .ToDictionaryAsync(s => s.Id, s => (s.Name_en, s.Name_zh)) + : ministryId.HasValue + ? await _db.ExpenseCategoryGroups.Where(g => ids.Contains(g.Id)) + .ToDictionaryAsync(g => g.Id, g => (g.Name_en, g.Name_zh)) + : await _db.Ministries.Where(m => ids.Contains(m.Id)) + .ToDictionaryAsync(m => m.Id, m => (m.Name_en, m.Name_zh)); + + return grouped.Select(x => + { + names.TryGetValue(x.Id, out var n); + return new BreakdownSliceDto { Id = x.Id, Name_en = n.En ?? "", Name_zh = n.Zh, Amount = x.Amount }; + }).OrderByDescending(s => s.Amount).ToList(); + } +} diff --git a/API/ROLAC.API/Services/IExpenseService.cs b/API/ROLAC.API/Services/IExpenseService.cs index 89e068f..e9d3228 100644 --- a/API/ROLAC.API/Services/IExpenseService.cs +++ b/API/ROLAC.API/Services/IExpenseService.cs @@ -6,7 +6,8 @@ public interface IExpenseService { Task> GetPagedAsync( int page, int pageSize, string? search, int? ministryId, - int? categoryGroupId, string? status, DateOnly? from, DateOnly? to); + int? categoryGroupId, string? status, DateOnly? from, DateOnly? to, + int? subCategoryId = null, string? statuses = null); Task> GetMineAsync(string userId, string? status, int page, int pageSize); Task GetByIdAsync(int id); Task CreateAsync(CreateExpenseRequest r, bool isFinance); diff --git a/API/ROLAC.API/Services/IFinanceDashboardService.cs b/API/ROLAC.API/Services/IFinanceDashboardService.cs new file mode 100644 index 0000000..b2a67c0 --- /dev/null +++ b/API/ROLAC.API/Services/IFinanceDashboardService.cs @@ -0,0 +1,18 @@ +using ROLAC.API.DTOs.Finance; +namespace ROLAC.API.Services; + +public interface IFinanceDashboardService +{ + /// All-time balance: total Givings minus total Paid+Approved expenses. + Task GetSummaryAsync(); + + /// Income (Givings) vs expense (Paid+Approved) totals within the date range. + Task GetIncomeExpenseAsync(DateOnly? from, DateOnly? to); + + /// + /// Expense totals grouped by the drill level implied by the supplied ids: + /// none -> by Ministry; ministryId -> by CategoryGroup; ministryId+categoryGroupId -> by SubCategory. + /// + Task> GetExpenseBreakdownAsync( + DateOnly? from, DateOnly? to, int? ministryId, int? categoryGroupId); +} diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index a8ddb1b..263f530 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -13,6 +13,7 @@ import { ExpenseCategoriesPageComponent } from './features/expense/pages/expense import { ExpensesPageComponent } from './features/expense/pages/expenses-page/expenses-page.component'; import { MyReimbursementsPageComponent } from './features/expense/pages/my-reimbursements-page/my-reimbursements-page.component'; import { MonthlyStatementPageComponent } from './features/expense/pages/monthly-statement-page/monthly-statement-page.component'; +import { FinanceDashboardPageComponent } from './features/finance-dashboard/pages/finance-dashboard-page/finance-dashboard-page.component'; export const routes: Routes = [ // Public routes @@ -33,6 +34,12 @@ export const routes: Routes = [ canActivate: [RoleGuard], data: { roles: ['super_admin'] }, }, + { + path: 'finance/dashboard', + component: FinanceDashboardPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, { path: 'finance/giving-categories', component: GivingCategoriesPageComponent, diff --git a/APP/src/app/features/expense/models/expense.model.ts b/APP/src/app/features/expense/models/expense.model.ts index e572426..f02c6c7 100644 --- a/APP/src/app/features/expense/models/expense.model.ts +++ b/APP/src/app/features/expense/models/expense.model.ts @@ -19,9 +19,10 @@ export interface ExpenseListItemDto { ministryId: number; ministryName: string; categoryGroupId: number; categoryGroupName: string; subCategoryId: number; subCategoryName: string; vendorName: string | null; memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean; + checkNumber: string | null; } export interface ExpenseDto extends ExpenseListItemDto { - checkNumber: string | null; notes: string | null; reviewNotes: string | null; + notes: string | null; reviewNotes: string | null; submittedBy: string | null; submittedAt: string | null; reviewedAt: string | null; paidAt: string | null; } export interface CreateExpenseRequest { diff --git a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html index 1460f95..2fe091c 100644 --- a/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html +++ b/APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html @@ -7,7 +7,7 @@
-
-
-

Recent Transactions

-
-
- -
-
-
-
{{ transaction.title }}
-
- {{ transaction.statusLabel }} -
-
-
-
${{ transaction.amount | number:'1.0-0' }}
-
{{ transaction.date | date:'MMM d, y' }}
-
-
-
-
-
- {{ transaction.progress }}% Complete -
-
-
- - -
-
- - - - -
-

No Recent Transactions

-

You don't have any recent transactions yet.

-
-
-
diff --git a/APP/src/app/portals/user-portal/user-portal.component.html b/APP/src/app/portals/user-portal/user-portal.component.html index 7c7a524..c582598 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.html +++ b/APP/src/app/portals/user-portal/user-portal.component.html @@ -142,7 +142,7 @@
-
diff --git a/APP/src/app/portals/user-portal/user-portal.component.ts b/APP/src/app/portals/user-portal/user-portal.component.ts index f685694..db58af9 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.ts +++ b/APP/src/app/portals/user-portal/user-portal.component.ts @@ -2,7 +2,26 @@ import { Component, OnInit, OnDestroy, HostListener } from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router, NavigationEnd, RouterModule, RouterOutlet } from '@angular/router'; import { IconsModule } from '@progress/kendo-angular-icons'; -import { SVGIcon, homeIcon, calendarIcon, userIcon, groupIcon } from '@progress/kendo-svg-icons'; +import { + SVGIcon, + homeIcon, + calendarIcon, + userIcon, + groupIcon, + usersOutlineIcon, + bedOutlineIcon, + pillsOutlineIcon, + graphIcon, + buildingsOutlineIcon, + banknoteOutlineIcon, + questionCircleIcon, + dollarIcon, + categorizeIcon, + moneyExchangeIcon, + fileReportIcon, + walletOutlineIcon, + handIcon, +} from '@progress/kendo-svg-icons'; import { AuthService, UserInfo } from '../../shared/services/auth.service'; import { Subject, takeUntil, filter } from 'rxjs'; @@ -35,14 +54,14 @@ export class UserPortalComponent implements OnInit, OnDestroy { public homeIcon: SVGIcon = homeIcon; public calendarIcon: SVGIcon = calendarIcon; - public peopleIcon: SVGIcon = userIcon; - public bedIcon: SVGIcon = userIcon; + public peopleIcon: SVGIcon = usersOutlineIcon; + public bedIcon: SVGIcon = bedOutlineIcon; public userIcon: SVGIcon = userIcon; - public pillIcon: SVGIcon = userIcon; - public chartIcon: SVGIcon = userIcon; - public buildingIcon: SVGIcon = userIcon; - public creditCardIcon: SVGIcon = userIcon; - public supportIcon: SVGIcon = userIcon; + public pillIcon: SVGIcon = pillsOutlineIcon; + public chartIcon: SVGIcon = graphIcon; + public buildingIcon: SVGIcon = buildingsOutlineIcon; + public creditCardIcon: SVGIcon = banknoteOutlineIcon; + public supportIcon: SVGIcon = questionCircleIcon; public mainNavItems: NavItem[] = [ { text: 'Dashboard', icon: this.homeIcon, path: '/user-portal/dashboard' }, @@ -71,16 +90,17 @@ export class UserPortalComponent implements OnInit, OnDestroy { ]; public financeNavItems: NavItem[] = [ - { text: 'Offering Entry', icon: this.creditCardIcon, path: '/user-portal/finance/offering-session' }, - { text: 'Givings', icon: this.creditCardIcon, path: '/user-portal/finance/givings' }, - { text: 'Giving Types', icon: this.creditCardIcon, path: '/user-portal/finance/giving-categories' }, - { text: 'Expenses', icon: this.creditCardIcon, path: '/user-portal/finance/expenses' }, - { text: 'Expense Categories', icon: this.creditCardIcon, path: '/user-portal/finance/expense-categories' }, - { text: 'Monthly Statement', icon: this.creditCardIcon, path: '/user-portal/finance/monthly-statement' }, + { text: 'Finance Dashboard', icon: graphIcon, path: '/user-portal/finance/dashboard' }, + { text: 'Offering Entry', icon: handIcon, path: '/user-portal/finance/offering-session' }, + { text: 'Givings', icon: dollarIcon, path: '/user-portal/finance/givings' }, + { text: 'Giving Types', icon: categorizeIcon, path: '/user-portal/finance/giving-categories' }, + { text: 'Expenses', icon: moneyExchangeIcon, path: '/user-portal/finance/expenses' }, + { text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories' }, + { text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement' }, ]; public personalNavItems: NavItem[] = [ - { text: 'My Reimbursements', icon: this.creditCardIcon, path: '/user-portal/reimbursements' }, + { text: 'My Reimbursements', icon: walletOutlineIcon, path: '/user-portal/reimbursements' }, ]; public showMemberAdminSection = false; @@ -199,6 +219,7 @@ export class UserPortalComponent implements OnInit, OnDestroy { 'settings': 'Settings', 'admin/members': 'Member Management', 'admin/users': 'User Management', + 'finance/dashboard': 'Finance Dashboard', 'finance/offering-session': 'Sunday Offering Entry', 'finance/givings': 'Givings', 'finance/giving-categories': 'Giving Types',