refactor finance.

This commit is contained in:
Chris Chen
2026-05-29 23:56:29 -07:00
parent 241870fe48
commit 769597d769
22 changed files with 1392 additions and 65 deletions
@@ -25,10 +25,11 @@ public class ExpensesController : ControllerBase
public async Task<IActionResult> 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")]
@@ -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<IActionResult> Summary()
=> Ok(await _svc.GetSummaryAsync());
[HttpGet("income-expense")]
public async Task<IActionResult> IncomeExpense([FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
=> Ok(await _svc.GetIncomeExpenseAsync(from, to));
[HttpGet("expense-breakdown")]
public async Task<IActionResult> ExpenseBreakdown(
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
[FromQuery] int? ministryId, [FromQuery] int? categoryGroupId)
=> Ok(await _svc.GetExpenseBreakdownAsync(from, to, ministryId, categoryGroupId));
}
+1 -1
View File
@@ -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; }
@@ -0,0 +1,25 @@
namespace ROLAC.API.DTOs.Finance;
/// <summary>All-time finance position for the dashboard balance card.</summary>
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
}
/// <summary>Income vs expense totals for a date range (the income/expense pie).</summary>
public class IncomeExpenseDto
{
public decimal Income { get; set; } // Givings in [from,to]
public decimal Expense { get; set; } // Paid+Approved expenses in [from,to]
}
/// <summary>One slice of the expense drill-down pie. Id is a ministry / group / sub-category id by level.</summary>
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; }
}
+217
View File
@@ -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');
+1
View File
@@ -127,6 +127,7 @@ builder.Services.AddScoped<ROLAC.API.Services.Storage.IFileStorage,
builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
builder.Services.AddScoped<IExpenseService, ExpenseService>();
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
// ---------------------------------------------------------------------------
// Swagger / MVC
+16 -2
View File
@@ -27,12 +27,24 @@ public class ExpenseService : IExpenseService
public async Task<PagedResult<ExpenseListItemDto>> 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<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Finance;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
/// <summary>
/// Read-only aggregation over Givings + Expenses for the Finance Dashboard.
/// Expense scope is Paid+Approved everywhere (decided with the user); income is Givings only.
/// </summary>
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<Expense> 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<FinanceSummaryDto> 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<IncomeExpenseDto> 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<List<BreakdownSliceDto>> 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<int, (string En, string? Zh)> 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();
}
}
+2 -1
View File
@@ -6,7 +6,8 @@ public interface IExpenseService
{
Task<PagedResult<ExpenseListItemDto>> 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<PagedResult<ExpenseListItemDto>> GetMineAsync(string userId, string? status, int page, int pageSize);
Task<ExpenseDto?> GetByIdAsync(int id);
Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance);
@@ -0,0 +1,18 @@
using ROLAC.API.DTOs.Finance;
namespace ROLAC.API.Services;
public interface IFinanceDashboardService
{
/// <summary>All-time balance: total Givings minus total Paid+Approved expenses.</summary>
Task<FinanceSummaryDto> GetSummaryAsync();
/// <summary>Income (Givings) vs expense (Paid+Approved) totals within the date range.</summary>
Task<IncomeExpenseDto> GetIncomeExpenseAsync(DateOnly? from, DateOnly? to);
/// <summary>
/// Expense totals grouped by the drill level implied by the supplied ids:
/// none -> by Ministry; ministryId -> by CategoryGroup; ministryId+categoryGroupId -> by SubCategory.
/// </summary>
Task<List<BreakdownSliceDto>> GetExpenseBreakdownAsync(
DateOnly? from, DateOnly? to, int? ministryId, int? categoryGroupId);
}
+7
View File
@@ -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,
@@ -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 {
@@ -7,7 +7,7 @@
<div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1">
Search
<kendo-textbox placeholder="Search description / vendor / member"
<kendo-textbox placeholder="Search description / vendor / member / check #"
[(ngModel)]="filter.search"
(keydown.enter)="applyFilter()">
</kendo-textbox>
@@ -76,6 +76,12 @@
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column title="Check #" [width]="90">
<ng-template kendoGridCellTemplate let-dataItem>
{{ dataItem.status === 'Paid' && dataItem.checkNumber ? dataItem.checkNumber : '—' }}
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Status" [width]="140">
<ng-template kendoGridCellTemplate let-dataItem>
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
@@ -10,6 +10,7 @@ import {
export interface ExpenseQuery {
page?: number; pageSize?: number; search?: string; ministryId?: number;
categoryGroupId?: number; status?: string; from?: string; to?: string;
subCategoryId?: number; statuses?: string;
}
@Injectable({ providedIn: 'root' })
@@ -0,0 +1,13 @@
// DTOs mirrored from the API (api/finance-dashboard).
export interface FinanceSummaryDto { totalIncome: number; totalExpenses: number; balance: number; }
export interface IncomeExpenseDto { income: number; expense: number; }
export interface BreakdownSliceDto { id: number; name_en: string; name_zh: string | null; amount: number; }
// View model: a chart slice with a display-ready bilingual label.
export interface PieSlice { id: number; label: string; amount: number; }
// Expense drill-down levels: Ministry -> Category Group -> Sub-Category.
export type DrillLevel = 'ministry' | 'group' | 'subcategory';
// One step in the drill breadcrumb. id is null for the root ('All').
export interface Crumb { level: DrillLevel; id: number | null; label: string; }
@@ -0,0 +1,183 @@
<div class="fin">
<!-- Page header -->
<header class="fin__head">
<div>
<span class="fin__eyebrow">River of Life · Finance</span>
<h1 class="fin__title">Finance Dashboard <span>財務儀表板</span></h1>
</div>
</header>
<!-- Top band: hero balance + supporting stats (all-time) -->
<section class="fin__band rise" style="--d: 0ms">
<div class="hero">
<div class="hero__glow"></div>
<span class="hero__label">Offering Balance · 奉獻餘額</span>
<div class="hero__value" [class.is-neg]="(summary?.balance ?? 0) < 0">
{{ (summary?.balance ?? 0) | currency }}
</div>
<span class="hero__sub">All-time · Total giving minus paid &amp; approved expenses</span>
<div class="hero__rings"></div>
</div>
<div class="stat stat--income">
<div class="stat__icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 19V5M12 5l-6 6M12 5l6 6"/></svg>
</div>
<span class="stat__label">Total Income · 總奉獻</span>
<span class="stat__value">{{ (summary?.totalIncome ?? 0) | currency }}</span>
</div>
<div class="stat stat--expense">
<div class="stat__icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 5v14M12 19l-6-6M12 19l6-6"/></svg>
</div>
<span class="stat__label">Total Expenses · 總支出</span>
<span class="stat__value">{{ (summary?.totalExpenses ?? 0) | currency }}</span>
</div>
</section>
<!-- Range filter -->
<section class="fin__filter rise" style="--d: 80ms">
<div class="chips">
<button type="button" class="chip" [class.is-active]="activeRange === 'month'" (click)="setQuickRange('month')">This Month</button>
<button type="button" class="chip" [class.is-active]="activeRange === 'lastMonth'" (click)="setQuickRange('lastMonth')">Last Month</button>
<button type="button" class="chip" [class.is-active]="activeRange === 'year'" (click)="setQuickRange('year')">This Year</button>
</div>
<div class="range">
<kendo-datepicker [(ngModel)]="from" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
[inputAttributes]="{ 'aria-label': 'From date' }"></kendo-datepicker>
<span class="range__sep"></span>
<kendo-datepicker [(ngModel)]="to" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
[inputAttributes]="{ 'aria-label': 'To date' }"></kendo-datepicker>
</div>
</section>
<!-- Analytics grid -->
<section class="fin__grid">
<!-- 2.1 Income vs Expense -->
<article class="card rise" style="--d: 160ms">
<div class="card__head">
<h2 class="card__title">Income vs Expense</h2>
<span class="card__zh">收入支出占比</span>
</div>
<div class="donut">
<kendo-chart [style.height.px]="230" [seriesColors]="incomeExpenseColors" [transitions]="false">
<kendo-chart-area background="transparent"></kendo-chart-area>
<kendo-chart-series>
<kendo-chart-series-item type="donut" [holeSize]="78" [data]="incomeExpense"
categoryField="label" field="amount" [border]="{ width: 0 }">
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend [visible]="false"></kendo-chart-legend>
<kendo-chart-tooltip format="{0:c}"></kendo-chart-tooltip>
</kendo-chart>
<div class="donut__center">
<span class="donut__cap">Net · 淨額</span>
<span class="donut__num" [class.is-neg]="rangeNet < 0">{{ rangeNet | currency:'USD':'symbol':'1.0-0' }}</span>
</div>
</div>
<ul class="legend">
<li class="legend__row">
<span class="legend__dot" [style.background]="incomeExpenseColors[0]"></span>
<span class="legend__name">Income · 收入</span>
<span class="legend__val">{{ rangeIncome | currency }}</span>
</li>
<li class="legend__row">
<span class="legend__dot" [style.background]="incomeExpenseColors[1]"></span>
<span class="legend__name">Expense · 支出</span>
<span class="legend__val">{{ rangeExpense | currency }}</span>
</li>
</ul>
</article>
<!-- 2.2 Expense breakdown drill-down -->
<article class="card rise" style="--d: 240ms">
<div class="card__head">
<h2 class="card__title">Expense Breakdown</h2>
<span class="card__zh">支出分類</span>
</div>
<!-- breadcrumb -->
<nav class="crumbs">
<ng-container *ngFor="let c of breadcrumb; let i = index; let last = last">
<button type="button" class="crumb" [class.is-current]="last" [disabled]="last" (click)="goToCrumb(i)">
{{ c.label }}
</button>
<span *ngIf="!last" class="crumb__sep"></span>
</ng-container>
</nav>
<div class="donut">
<kendo-chart [style.height.px]="216" [seriesColors]="palette" (seriesClick)="onSliceClick($event)" [transitions]="false">
<kendo-chart-area background="transparent"></kendo-chart-area>
<kendo-chart-series>
<kendo-chart-series-item type="donut" [holeSize]="72" [data]="breakdown"
categoryField="label" field="amount" [border]="{ width: 0 }">
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend [visible]="false"></kendo-chart-legend>
<kendo-chart-tooltip format="{0:c}"></kendo-chart-tooltip>
</kendo-chart>
<div class="donut__center" *ngIf="breakdown.length">
<span class="donut__cap">Total · 合計</span>
<span class="donut__num">{{ breakdownTotal | currency:'USD':'symbol':'1.0-0' }}</span>
</div>
<div *ngIf="breakdown.length === 0" class="empty">No expenses in this range<br><span>此範圍無支出</span></div>
</div>
<ul class="legend legend--scroll" *ngIf="breakdown.length">
<li class="legend__row legend__row--btn" *ngFor="let s of breakdown; let i = index"
(click)="drillInto(s)" [class.is-selected]="selectedSubId === s.id">
<span class="legend__dot" [style.background]="palette[i % palette.length]"></span>
<span class="legend__name">{{ s.label }}</span>
<span class="legend__pct">{{ percent(s.amount, breakdownTotal) | number:'1.0-0' }}%</span>
<span class="legend__val">{{ s.amount | currency }}</span>
</li>
</ul>
<p class="hint">{{ drillHint }}</p>
</article>
<!-- 2.2.1 Expense detail beside the drill chart -->
<article class="card card--wide rise" style="--d: 320ms">
<div class="card__head">
<h2 class="card__title">Expense Detail</h2>
<span class="card__zh">支出明細 · {{ currentLevelLabel }}</span>
</div>
<kendo-grid class="detail"
[data]="detailRows"
[pageable]="{ info: false, previousNext: true, buttonCount: 4 }"
[pageSize]="detailPageSize"
[skip]="detailSkip"
[loading]="detailLoading"
[height]="430"
(pageChange)="onDetailPageChange($event)">
<kendo-grid-column field="expenseDate" title="Date" [width]="104"></kendo-grid-column>
<kendo-grid-column title="Item">
<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>
</div>
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Status" [width]="110">
<ng-template kendoGridCellTemplate let-d>
<span class="pill" [ngClass]="statusClass(d.status)">{{ d.status }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="118">
<ng-template kendoGridCellTemplate let-d>
<span class="cell-amt">{{ d.amount | currency }}</span>
</ng-template>
</kendo-grid-column>
<ng-template kendoGridNoRecordsTemplate>
<div class="empty empty--grid">No matching expenses<br><span>沒有符合的支出</span></div>
</ng-template>
</kendo-grid>
</article>
</section>
</div>
@@ -0,0 +1,499 @@
:host {
--ink: #1c2a38;
--ink-soft: #50647c;
--line: rgba(23, 65, 99, 0.1);
--card-bg: #ffffff;
--income: #0c8d42;
--expense: #d8443c;
--radius: 18px;
--shadow: 0 1px 2px rgba(16, 38, 58, 0.04), 0 12px 32px -16px rgba(16, 38, 58, 0.22);
display: block;
}
/* ---------- Page shell ---------- */
.fin {
padding: 28px clamp(16px, 3vw, 36px) 48px;
color: var(--ink);
background:
radial-gradient(120% 80% at 100% -10%, rgba(2, 121, 207, 0.08), transparent 55%),
radial-gradient(90% 70% at -10% 0%, rgba(22, 212, 203, 0.07), transparent 50%),
#f2f4f7;
min-height: 100%;
}
.fin__head {
margin-bottom: 22px;
}
.fin__eyebrow {
display: inline-block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--kendo-color-primary, #0279cf);
margin-bottom: 6px;
}
.fin__title {
font-size: clamp(26px, 3.2vw, 36px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
span {
font-weight: 400;
color: var(--ink-soft);
margin-left: 8px;
font-size: 0.62em;
}
}
/* ---------- Top band ---------- */
.fin__band {
display: grid;
gap: 18px;
grid-template-columns: 1fr;
margin-bottom: 18px;
}
.hero {
position: relative;
overflow: hidden;
padding: 26px 28px;
border-radius: var(--radius);
color: #fff;
background: linear-gradient(135deg, #15588e 0%, #0279cf 56%, #16a7c9 120%);
box-shadow: 0 20px 44px -22px rgba(2, 90, 160, 0.7);
display: flex;
flex-direction: column;
justify-content: center;
min-height: 168px;
}
.hero__glow {
position: absolute;
width: 320px;
height: 320px;
top: -160px;
right: -80px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.35), transparent 65%);
pointer-events: none;
}
.hero__rings {
position: absolute;
right: -70px;
bottom: -90px;
width: 240px;
height: 240px;
border-radius: 50%;
background:
repeating-radial-gradient(circle, rgba(255, 255, 255, 0.14) 0 1px, transparent 1px 22px);
mask: radial-gradient(circle, #000 40%, transparent 72%);
pointer-events: none;
}
.hero__label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.82);
}
.hero__value {
font-size: clamp(34px, 4.6vw, 52px);
font-weight: 750;
letter-spacing: -0.03em;
line-height: 1.05;
margin: 6px 0 4px;
font-variant-numeric: tabular-nums;
&.is-neg { color: #ffd7d2; }
}
.hero__sub {
font-size: 12.5px;
color: rgba(255, 255, 255, 0.78);
position: relative;
z-index: 1;
}
.stat {
position: relative;
padding: 22px 22px 20px;
border-radius: var(--radius);
background: var(--card-bg);
border: 1px solid var(--line);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
justify-content: center;
}
.stat__icon {
position: absolute;
top: 18px;
right: 18px;
width: 38px;
height: 38px;
border-radius: 11px;
display: grid;
place-items: center;
svg {
width: 20px;
height: 20px;
fill: none;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
}
.stat--income .stat__icon { background: rgba(12, 141, 66, 0.12); svg { stroke: var(--income); } }
.stat--expense .stat__icon { background: rgba(216, 68, 60, 0.12); svg { stroke: var(--expense); } }
.stat__label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--ink-soft);
text-transform: uppercase;
}
.stat__value {
font-size: clamp(24px, 2.6vw, 30px);
font-weight: 700;
letter-spacing: -0.02em;
margin-top: 6px;
font-variant-numeric: tabular-nums;
}
.stat--income .stat__value { color: var(--income); }
.stat--expense .stat__value { color: var(--expense); }
/* ---------- Filter ---------- */
.fin__filter {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 22px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid var(--line);
backdrop-filter: blur(6px);
}
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
border: 1px solid var(--line);
background: #fff;
color: var(--ink-soft);
font: inherit;
font-size: 13px;
font-weight: 600;
padding: 7px 14px;
border-radius: 999px;
cursor: pointer;
transition: all 0.16s ease;
&:hover { border-color: var(--kendo-color-primary, #0279cf); color: var(--kendo-color-primary, #0279cf); }
&.is-active {
background: var(--kendo-color-primary, #0279cf);
border-color: var(--kendo-color-primary, #0279cf);
color: #fff;
box-shadow: 0 8px 18px -8px rgba(2, 121, 207, 0.7);
}
}
.range { display: flex; align-items: center; gap: 6px; }
.range__sep { color: var(--ink-soft); }
/* ---------- Analytics grid ---------- */
.fin__grid {
display: grid;
gap: 18px;
grid-template-columns: 1fr;
}
.card {
background: var(--card-bg);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 18px 18px 16px;
display: flex;
flex-direction: column;
}
.card__head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 6px;
}
.card__title {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.card__zh {
font-size: 12px;
color: var(--ink-soft);
}
/* ---------- Donut + center overlay ---------- */
.donut {
position: relative;
}
.donut__center {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
gap: 2px;
}
.donut__cap {
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-soft);
}
.donut__num {
font-size: 21px;
font-weight: 750;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
&.is-neg { color: var(--expense); }
}
/* ---------- Custom legend ---------- */
.legend {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.legend--scroll {
max-height: 156px;
overflow-y: auto;
padding-right: 2px;
&::-webkit-scrollbar { width: 6px; }
&::-webkit-scrollbar-thumb { background: var(--line); border-radius: 999px; }
}
.legend__row {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 8px;
border-radius: 9px;
font-size: 13px;
}
.legend__row--btn {
cursor: pointer;
transition: background 0.14s ease;
&:hover { background: var(--kendo-color-primary-subtle, #daecfb); }
&.is-selected {
background: var(--kendo-color-primary-subtle, #daecfb);
box-shadow: inset 2px 0 0 var(--kendo-color-primary, #0279cf);
}
}
.legend__dot {
width: 11px;
height: 11px;
border-radius: 4px;
flex: none;
}
.legend__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ink);
}
.legend__pct {
font-size: 12px;
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
width: 38px;
text-align: right;
}
.legend__val {
font-weight: 650;
font-variant-numeric: tabular-nums;
text-align: right;
}
.hint {
margin-top: 10px;
font-size: 11.5px;
color: var(--ink-soft);
text-align: center;
}
/* ---------- Breadcrumb ---------- */
.crumbs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
margin: 4px 0 10px;
}
.crumb {
border: none;
background: var(--kendo-color-base-subtle, #e6eaef);
color: var(--ink-soft);
font: inherit;
font-size: 12px;
font-weight: 600;
padding: 4px 11px;
border-radius: 999px;
cursor: pointer;
transition: all 0.14s ease;
&:hover:not(:disabled) { background: var(--kendo-color-primary-subtle, #daecfb); color: var(--kendo-color-primary-emphasis, #15588e); }
&.is-current {
background: var(--kendo-color-primary, #0279cf);
color: #fff;
cursor: default;
}
}
.crumb__sep { color: var(--ink-soft); font-size: 13px; }
/* ---------- Empty states ---------- */
.empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-size: 13px;
font-weight: 600;
color: var(--ink-soft);
span { font-weight: 400; font-size: 12px; }
}
.empty--grid {
position: static;
padding: 36px 0;
}
/* ---------- Detail grid (Kendo overrides) ---------- */
.detail {
border: none;
background: transparent;
font-family: inherit;
font-size: 13px;
}
:host ::ng-deep .detail {
.k-grid-header, .k-grid-header-wrap { border: none; background: transparent; }
.k-table-th {
border: none;
background: transparent;
color: var(--ink-soft);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding-block: 8px;
}
.k-grid-content { background: transparent; }
td.k-table-td {
border: none;
border-top: 1px solid var(--line);
padding-block: 9px;
vertical-align: middle;
}
.k-table-row:hover td.k-table-td { background: var(--kendo-color-primary-subtle, #daecfb); }
.k-grid-pager {
border: none;
background: transparent;
color: var(--ink-soft);
padding-top: 10px;
}
col:last-child, .k-table-td:last-child, .k-table-th:last-child { text-align: right; }
}
.cell-item { display: flex; flex-direction: column; line-height: 1.3; }
.cell-item__desc { font-weight: 600; color: var(--ink); }
.cell-item__sub { font-size: 11.5px; color: var(--ink-soft); }
.cell-amt {
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--ink);
}
.pill {
display: inline-block;
font-size: 11px;
font-weight: 700;
padding: 3px 10px;
border-radius: 999px;
letter-spacing: 0.02em;
&.is-paid { background: var(--kendo-color-success-subtle, #d9fce8); color: #0c8d42; }
&.is-approved { background: var(--kendo-color-primary-subtle, #daecfb); color: var(--kendo-color-primary-emphasis, #15588e); }
}
/* ---------- Responsive escalation ---------- */
@media (min-width: 760px) {
.fin__band { grid-template-columns: 1fr 1fr; }
.hero { grid-column: 1 / -1; }
.fin__grid { grid-template-columns: 1fr 1fr; }
.card--wide { grid-column: 1 / -1; }
}
@media (min-width: 1200px) {
.fin__band { grid-template-columns: 1.7fr 1fr 1fr; }
.hero { grid-column: auto; }
.fin__grid { grid-template-columns: 320px minmax(0, 1fr) minmax(0, 1.25fr); }
.card--wide { grid-column: auto; }
}
/* ---------- Entrance ---------- */
.rise {
opacity: 0;
transform: translateY(14px);
animation: rise 0.5s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: var(--d, 0ms);
}
@keyframes rise {
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.rise { animation: none; opacity: 1; transform: none; }
}
@@ -0,0 +1,204 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ChartsModule, SeriesClickEvent } from '@progress/kendo-angular-charts';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DateUtils } from '../../../../shared/utilities/date-utils';
import { ExpenseApiService } from '../../../expense/services/expense-api.service';
import { ExpenseListItemDto } from '../../../expense/models/expense.model';
import { FinanceDashboardApiService } from '../../services/finance-dashboard-api.service';
import { FinanceSummaryDto, PieSlice, DrillLevel, Crumb } from '../../models/finance-dashboard.model';
// Expense scope for the dashboard: Paid + Approved (shared by the pies and the detail grid).
const DASHBOARD_STATUSES = 'Paid,Approved';
type QuickRange = 'month' | 'lastMonth' | 'year' | null;
@Component({
selector: 'app-finance-dashboard-page',
standalone: true,
imports: [CommonModule, FormsModule, ChartsModule, DateInputsModule, GridModule, ButtonsModule],
templateUrl: './finance-dashboard-page.component.html',
styleUrls: ['./finance-dashboard-page.component.scss'],
})
export class FinanceDashboardPageComponent implements OnInit {
// All-time balance card (independent of the date range).
summary?: FinanceSummaryDto;
// Date range driving the charts + detail table (defaults to the current month).
from: Date = DateUtils.getFirstDayOfCurrentMonth();
to: Date = DateUtils.getLastDayOfCurrentMonth();
activeRange: QuickRange = 'month';
// Charts.
incomeExpense: PieSlice[] = [];
breakdown: PieSlice[] = [];
// Curated colour palettes (harmonised with the app's blue/teal brand).
readonly incomeExpenseColors = ['#0c8d42', '#d8443c'];
readonly palette = ['#0279cf', '#16b3c9', '#15588e', '#5b8def', '#7f76a9', '#d6ad1c', '#2fa37a', '#c35573'];
// Drill state: Ministry -> Category Group -> Sub-Category.
level: DrillLevel = 'ministry';
selectedMinistryId: number | null = null;
selectedGroupId: number | null = null;
selectedSubId: number | null = null;
breadcrumb: Crumb[] = [{ level: 'ministry', id: null, label: 'All / 全部' }];
// Detail grid.
detailRows: ExpenseListItemDto[] = [];
detailTotal = 0;
detailPage = 1;
detailPageSize = 8;
detailLoading = false;
constructor(
private api: FinanceDashboardApiService,
private expenseApi: ExpenseApiService,
) {}
ngOnInit(): void {
this.api.getSummary().subscribe(s => (this.summary = s));
this.reloadRange();
}
// Local Y/M/D — never toISOString() (it shifts the day across timezones).
private fmt(d: Date): string { return DateUtils.format(d, 'yyyy-MM-dd'); }
/** Date range changed: refresh income/expense and reset the drill to the top. */
reloadRange(): void {
if (!this.from || !this.to) return;
const from = this.fmt(this.from), to = this.fmt(this.to);
this.api.getIncomeExpense(from, to).subscribe(r => {
this.incomeExpense = [
{ id: 1, label: 'Income / 收入', amount: r.income },
{ id: 2, label: 'Expense / 支出', amount: r.expense },
];
});
this.resetDrill();
this.loadBreakdown();
this.loadDetail();
}
/** Quick range chips. */
setQuickRange(range: Exclude<QuickRange, null>): void {
const now = new Date();
if (range === 'month') {
this.from = new Date(now.getFullYear(), now.getMonth(), 1);
this.to = new Date(now.getFullYear(), now.getMonth() + 1, 0);
} else if (range === 'lastMonth') {
this.from = new Date(now.getFullYear(), now.getMonth() - 1, 1);
this.to = new Date(now.getFullYear(), now.getMonth(), 0);
} else {
this.from = new Date(now.getFullYear(), 0, 1);
this.to = new Date(now.getFullYear(), 11, 31);
}
this.activeRange = range;
this.reloadRange();
}
onManualDateChange(): void {
this.activeRange = null;
this.reloadRange();
}
private resetDrill(): void {
this.level = 'ministry';
this.selectedMinistryId = null;
this.selectedGroupId = null;
this.selectedSubId = null;
this.breadcrumb = [{ level: 'ministry', id: null, label: 'All / 全部' }];
}
private loadBreakdown(): void {
this.api.getBreakdown(this.fmt(this.from), this.fmt(this.to), this.selectedMinistryId, this.selectedGroupId)
.subscribe(slices => (this.breakdown = slices));
}
private loadDetail(): void {
this.detailLoading = true;
this.expenseApi.getPaged({
statuses: DASHBOARD_STATUSES,
from: this.fmt(this.from), to: this.fmt(this.to),
ministryId: this.selectedMinistryId ?? undefined,
categoryGroupId: this.selectedGroupId ?? undefined,
subCategoryId: this.selectedSubId ?? undefined,
page: this.detailPage, pageSize: this.detailPageSize,
}).subscribe({
next: r => { this.detailRows = r.items; this.detailTotal = r.totalCount; this.detailLoading = false; },
error: () => { this.detailLoading = false; },
});
}
onSliceClick(e: SeriesClickEvent): void { this.drillInto(e.dataItem as PieSlice); }
/** Drill deeper into a slice (from a chart click or a legend row click). */
drillInto(slice: PieSlice): void {
if (this.level === 'ministry') {
this.selectedMinistryId = slice.id;
this.level = 'group';
this.breadcrumb.push({ level: 'group', id: slice.id, label: slice.label });
} else if (this.level === 'group') {
this.selectedGroupId = slice.id;
this.level = 'subcategory';
this.breadcrumb.push({ level: 'subcategory', id: slice.id, label: slice.label });
} else {
// Leaf: filter the detail table only; no further drill.
this.selectedSubId = this.selectedSubId === slice.id ? null : slice.id;
this.detailPage = 1;
this.loadDetail();
return;
}
this.detailPage = 1;
this.loadBreakdown();
this.loadDetail();
}
/** Step back up via the breadcrumb. */
goToCrumb(i: number): void {
this.breadcrumb = this.breadcrumb.slice(0, i + 1);
const last = this.breadcrumb[this.breadcrumb.length - 1];
this.selectedSubId = null;
if (last.level === 'ministry') {
this.selectedMinistryId = null;
this.selectedGroupId = null;
this.level = 'ministry';
} else if (last.level === 'group') {
this.selectedGroupId = null;
this.level = 'group';
}
this.detailPage = 1;
this.loadBreakdown();
this.loadDetail();
}
onDetailPageChange(e: PageChangeEvent): void {
this.detailPage = Math.floor(e.skip / this.detailPageSize) + 1;
this.loadDetail();
}
get detailSkip(): number { return (this.detailPage - 1) * this.detailPageSize; }
// --- Derived figures for the donut centres + custom legends ---
get rangeIncome(): number { return this.incomeExpense[0]?.amount ?? 0; }
get rangeExpense(): number { return this.incomeExpense[1]?.amount ?? 0; }
get rangeNet(): number { return this.rangeIncome - this.rangeExpense; }
get breakdownTotal(): number { return this.breakdown.reduce((s, x) => s + x.amount, 0); }
get currentLevelLabel(): string { return this.breadcrumb[this.breadcrumb.length - 1]?.label ?? ''; }
/** The drill hint shown under the breakdown donut. */
get drillHint(): string {
if (this.level === 'ministry') return 'Click a ministry to drill in / 點選 Ministry 深入';
if (this.level === 'group') return 'Click a category group / 點選 Category Group';
return 'Click a sub-category to filter the table / 點選明細';
}
percent(amount: number, total: number): number {
return total > 0 ? (amount / total) * 100 : 0;
}
statusClass(status: string): string {
return ({ Paid: 'is-paid', Approved: 'is-approved' } as Record<string, string>)[status] ?? '';
}
}
@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import { bilingual } from '../../../shared/i18n/bilingual';
import { FinanceSummaryDto, IncomeExpenseDto, BreakdownSliceDto, PieSlice } from '../models/finance-dashboard.model';
@Injectable({ providedIn: 'root' })
export class FinanceDashboardApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('finance-dashboard');
}
private toParams(q: Record<string, unknown>): HttpParams {
let p = new HttpParams();
for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== '') p = p.set(k, String(v));
return p;
}
/** All-time balance card (ignores any date range). */
getSummary(): Observable<FinanceSummaryDto> {
return this.http.get<FinanceSummaryDto>(`${this.endpoint}/summary`);
}
/** Income vs expense totals for the selected date range. */
getIncomeExpense(from: string, to: string): Observable<IncomeExpenseDto> {
return this.http.get<IncomeExpenseDto>(`${this.endpoint}/income-expense`, { params: this.toParams({ from, to }) });
}
/** Expense breakdown slices for the current drill level; bilingual labels applied here. */
getBreakdown(from: string, to: string, ministryId?: number | null, categoryGroupId?: number | null): Observable<PieSlice[]> {
return this.http.get<BreakdownSliceDto[]>(`${this.endpoint}/expense-breakdown`,
{ params: this.toParams({ from, to, ministryId, categoryGroupId }) }).pipe(
map(list => list.map(s => ({ id: s.id, label: bilingual(s.name_en, s.name_zh), amount: s.amount }))));
}
}
@@ -65,47 +65,7 @@
</div>
<!-- Recent Transactions -->
<div class="section">
<div class="section-header">
<h2>Recent Transactions</h2>
</div>
<div class="transactions-list">
<!-- Transactions List -->
<div *ngIf="recentTransactions.length > 0">
<div *ngFor="let transaction of recentTransactions" class="transaction-card">
<div class="transaction-header">
<div class="transaction-title">{{ transaction.title }}</div>
<div class="transaction-status" [class]="transaction.status">
{{ transaction.statusLabel }}
</div>
</div>
<div class="transaction-details">
<div class="transaction-amount">${{ transaction.amount | number:'1.0-0' }}</div>
<div class="transaction-date">{{ transaction.date | date:'MMM d, y' }}</div>
</div>
<div class="transaction-progress">
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="transaction.progress"></div>
</div>
<span class="progress-text">{{ transaction.progress }}% Complete</span>
</div>
</div>
</div>
<!-- Empty State -->
<div *ngIf="recentTransactions.length === 0" class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</div>
<h3>No Recent Transactions</h3>
<p>You don't have any recent transactions yet.</p>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="section">
@@ -142,7 +142,7 @@
<div class="header-right">
<div class="header-actions">
<button class="action-btn" title="Notifications">
<!-- <button class="action-btn" title="Notifications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
@@ -156,7 +156,7 @@
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
</button>
</button> -->
</div>
</div>
</header>
@@ -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',