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
@@ -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 }))));
}
}