refactor finance.
This commit is contained in:
@@ -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));
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
+183
@@ -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 & 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>
|
||||
+499
@@ -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; }
|
||||
}
|
||||
+204
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user