refactor finance.
This commit is contained in:
@@ -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