feat(web): Form 990 functional-expenses report page, route, and nav

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-06-24 19:49:02 -07:00
parent 677cb8f054
commit 5aaac3246d
6 changed files with 140 additions and 0 deletions
+10
View File
@@ -20,6 +20,7 @@ import { FinanceDashboardPageComponent } from './features/finance-dashboard/page
import { DisbursementPageComponent } from './features/disbursement/pages/disbursement-page/disbursement-page.component';
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
import { Form990ReportPageComponent } from './features/finance-report/pages/form990-report-page/form990-report-page.component';
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
@@ -206,6 +207,15 @@ export const routes: Routes = [
title: 'Church Profile', titleZh: '教會資料', section: 'Finance',
},
},
{
path: 'finance/form-990-report',
component: Form990ReportPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.Form990Report, action: 'read' },
title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance',
},
},
]
},
@@ -6,3 +6,22 @@ export interface Form990ExpenseLineDto {
sortOrder: number;
label?: string; // bilingual "code — name", filled by service
}
export interface FunctionalExpenseRowDto {
lineCode: string;
name_en: string;
name_zh: string | null;
program: number;
managementGeneral: number;
fundraising: number;
total: number;
}
export interface FunctionalExpenseStatementDto {
rows: FunctionalExpenseRowDto[];
programTotal: number;
managementGeneralTotal: number;
fundraisingTotal: number;
grandTotal: number;
unmappedExpenseCount: number;
}
@@ -0,0 +1,39 @@
<div class="flex flex-wrap items-end gap-3 mb-4">
<label class="flex flex-col gap-1"><span>From / 起</span>
<kendo-datepicker [(value)]="from"></kendo-datepicker></label>
<label class="flex flex-col gap-1"><span>To / 迄</span>
<kendo-datepicker [(value)]="to"></kendo-datepicker></label>
<button kendoButton themeColor="primary" (click)="load()">Apply / 套用</button>
</div>
<div *ngIf="statement?.unmappedExpenseCount" class="mb-3 p-2 rounded bg-amber-50 text-amber-800 text-sm">
{{ statement?.unmappedExpenseCount }} expense(s) have no Form 990 mapping — counted under line 24.
尚有支出未對應 990 行,已暫計入 line 24。
</div>
<div class="hidden md:block">
<kendo-grid [data]="statement?.rows ?? []">
<kendo-grid-column field="lineCode" title="Line" [width]="80"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Description / 說明"></kendo-grid-column>
<kendo-grid-column field="program" title="Program" format="{0:c2}" [width]="140"></kendo-grid-column>
<kendo-grid-column field="managementGeneral" title="Mgmt & General" format="{0:c2}" [width]="150"></kendo-grid-column>
<kendo-grid-column field="fundraising" title="Fundraising" format="{0:c2}" [width]="140"></kendo-grid-column>
<kendo-grid-column field="total" title="Total" format="{0:c2}" [width]="140"></kendo-grid-column>
</kendo-grid>
<div class="flex justify-end gap-8 mt-2 font-semibold" *ngIf="statement">
<span>Program: {{ statement.programTotal | currency }}</span>
<span>M&amp;G: {{ statement.managementGeneralTotal | currency }}</span>
<span>Fundraising: {{ statement.fundraisingTotal | currency }}</span>
<span>Total: {{ statement.grandTotal | currency }}</span>
</div>
</div>
<div class="md:hidden flex flex-col gap-3">
<div *ngFor="let row of statement?.rows ?? []" class="rounded border p-3">
<div class="font-semibold">{{ row.lineCode }} — {{ row.name_en }}</div>
<div class="text-sm flex justify-between"><span>Program</span><span>{{ row.program | currency }}</span></div>
<div class="text-sm flex justify-between"><span>M&amp;G</span><span>{{ row.managementGeneral | currency }}</span></div>
<div class="text-sm flex justify-between"><span>Fundraising</span><span>{{ row.fundraising | currency }}</span></div>
<div class="text-sm flex justify-between font-semibold"><span>Total</span><span>{{ row.total | currency }}</span></div>
</div>
</div>
@@ -0,0 +1,46 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { DatePickerModule } from '@progress/kendo-angular-dateinputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { Form990ReportApiService } from '../../services/form990-report-api.service';
import { FunctionalExpenseStatementDto } from '../../models/form990-report.model';
@Component({
selector: 'app-form990-report-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, DatePickerModule, ButtonsModule],
templateUrl: './form990-report-page.component.html',
})
export class Form990ReportPageComponent implements OnInit {
from: Date = new Date(new Date().getFullYear(), 0, 1);
to: Date = new Date(new Date().getFullYear(), 11, 31);
statement: FunctionalExpenseStatementDto | null = null;
loading = false;
constructor(private api: Form990ReportApiService) {}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
const fmt = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
this.api.getFunctionalExpenses(fmt(this.from), fmt(this.to)).subscribe({
next: (statement) => {
this.statement = statement;
this.loading = false;
},
error: () => {
this.loading = false;
},
});
}
}
@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import { FunctionalExpenseStatementDto } from '../models/form990-report.model';
@Injectable({ providedIn: 'root' })
export class Form990ReportApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('form990-report');
}
getFunctionalExpenses(from?: string, to?: string): Observable<FunctionalExpenseStatementDto> {
let params = new HttpParams();
if (from) { params = params.set('from', from); }
if (to) { params = params.set('to', to); }
return this.http.get<FunctionalExpenseStatementDto>(
`${this.endpoint}/functional-expenses`,
{ params }
);
}
}
@@ -108,6 +108,8 @@ export class UserPortalComponent implements OnInit, OnDestroy {
permission: { module: PermissionModules.FinanceDashboard, action: 'read' } },
{ text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement',
permission: { module: PermissionModules.MonthlyStatements, action: 'read' } },
{ text: 'Form 990 Report', icon: fileReportIcon, path: '/user-portal/finance/form-990-report',
permission: { module: PermissionModules.Form990Report, action: 'read' } },
],
},
{