Files
ROLAC/APP/src/app/portals/user-portal/user-portal.component.ts
T
Chris Chen 099303995b fix(expense-snapshot): gate page on Expenses:write to match the write-only API
The snapshot management page backs an API that gates every action on
Expenses:Write, so a read-only user reaching it via a read-gated nav/route
would hit a silent 403 and a blank page. Require write for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:21:11 -07:00

347 lines
12 KiB
TypeScript

import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, NavigationEnd, RouterModule, RouterOutlet } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { IconsModule } from '@progress/kendo-angular-icons';
import {
SVGIcon,
homeIcon,
userIcon,
groupIcon,
graphIcon,
buildingsOutlineIcon,
banknoteOutlineIcon,
dollarIcon,
categorizeIcon,
moneyExchangeIcon,
fileReportIcon,
walletOutlineIcon,
handIcon,
searchIcon,
xIcon,
chevronDownIcon,
lockIcon,
gearIcon,
} from '@progress/kendo-svg-icons';
import { AuthService, UserInfo } from '../../shared/services/auth.service';
import { PageHeaderService } from '../../shared/services/page-header.service';
import { PermissionService } from '../../core/services/permission.service';
import { PermissionAction, PermissionModules } from '../../core/models/permission.model';
import { Subject, takeUntil, filter } from 'rxjs';
interface NavItem {
text: string;
icon: SVGIcon;
path: string;
active?: boolean;
/** When set, the item is shown only if the user has this permission. */
permission?: { module: string; action: PermissionAction };
}
interface NavGroup {
text: string;
icon?: SVGIcon;
items: NavItem[];
expanded: boolean;
}
@Component({
selector: 'app-user-portal',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
RouterOutlet,
IconsModule,
],
templateUrl: './user-portal.component.html',
styleUrls: ['./user-portal.component.scss']
})
export class UserPortalComponent implements OnInit, OnDestroy {
sidebarCollapsed = false;
isMobile = false;
currentUser: UserInfo | null = null;
currentPageTitle = 'Dashboard';
currentPageTitleZh = '';
currentSection = 'Home';
public searchQuery = '';
public homeIcon: SVGIcon = homeIcon;
public userIcon: SVGIcon = userIcon;
public searchIcon: SVGIcon = searchIcon;
public clearIcon: SVGIcon = xIcon;
public chevronDownIcon: SVGIcon = chevronDownIcon;
public mainNavItems: NavItem[] = [
{ text: 'Dashboard', icon: this.homeIcon, path: '/user-portal/dashboard' },
];
public memberAdminNavItems: NavItem[] = [
{ text: 'Members', icon: groupIcon, path: '/user-portal/admin/members',
permission: { module: PermissionModules.Members, action: 'read' } },
{ text: 'Ministries', icon: groupIcon, path: '/user-portal/admin/ministries',
permission: { module: PermissionModules.Ministries, action: 'read' } },
];
public userAdminNavItems: NavItem[] = [
{ text: 'User Management', icon: userIcon, path: '/user-portal/admin/users',
permission: { module: PermissionModules.Users, action: 'read' } },
{ text: 'Role Permissions', icon: lockIcon, path: '/user-portal/admin/permissions',
permission: { module: PermissionModules.Permissions, action: 'read' } },
];
public logsAdminNavItems: NavItem[] = [
{ text: 'System Logs', icon: fileReportIcon, path: '/user-portal/admin/logs/system',
permission: { module: PermissionModules.SystemLogs, action: 'read' } },
{ text: 'Audit Logs', icon: fileReportIcon, path: '/user-portal/admin/logs/audit',
permission: { module: PermissionModules.AuditLogs, action: 'read' } },
];
public financeGroups: NavGroup[] = [
{
text: 'Overview',
expanded: false,
items: [
{ text: 'Finance Dashboard', icon: graphIcon, path: '/user-portal/finance/dashboard',
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' } },
],
},
{
text: 'Income',
expanded: false,
items: [
{ text: 'Offering Entry', icon: handIcon, path: '/user-portal/finance/offering-session',
permission: { module: PermissionModules.OfferingSessions, action: 'read' } },
{ text: 'Givings', icon: dollarIcon, path: '/user-portal/finance/givings',
permission: { module: PermissionModules.Givings, action: 'read' } },
{ text: 'Giving Types', icon: categorizeIcon, path: '/user-portal/finance/giving-categories',
permission: { module: PermissionModules.GivingCategories, action: 'read' } },
],
},
{
text: 'Expenses',
expanded: false,
items: [
{ text: 'Expenses', icon: moneyExchangeIcon, path: '/user-portal/finance/expenses',
permission: { module: PermissionModules.Expenses, action: 'read' } },
{ text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories',
permission: { module: PermissionModules.ExpenseCategories, action: 'read' } },
{ text: 'Expense Snapshots', icon: categorizeIcon, path: '/user-portal/finance/expense-snapshots',
permission: { module: PermissionModules.Expenses, action: 'write' } },
{ text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements',
permission: { module: PermissionModules.Disbursements, action: 'read' } },
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register',
permission: { module: PermissionModules.Disbursements, action: 'read' } },
],
},
{
text: 'Settings',
expanded: false,
items: [
{ text: 'Church Profile', icon: buildingsOutlineIcon, path: '/user-portal/finance/church-profile',
permission: { module: PermissionModules.ChurchProfile, action: 'read' } },
],
},
];
public personalNavItems: NavItem[] = [
{ text: 'My Reimbursements', icon: walletOutlineIcon, path: '/user-portal/reimbursements' },
{ text: 'Account Settings', icon: gearIcon, path: '/user-portal/account' },
];
public showMemberAdminSection = false;
public showUserAdminSection = false;
public showLogsAdminSection = false;
public showFinanceSection = false;
private destroy$ = new Subject<void>();
constructor(
private authService: AuthService,
private permissions: PermissionService,
private router: Router,
private activatedRoute: ActivatedRoute,
public pageHeader: PageHeaderService
) { }
ngOnInit(): void {
this.checkScreenSize();
this.setupUserSubscription();
this.setupRouteSubscription();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
@HostListener('window:resize', ['$event'])
onResize(event: any): void {
this.checkScreenSize();
}
private checkScreenSize(): void {
this.isMobile = window.innerWidth <= 768;
if (this.isMobile) {
this.sidebarCollapsed = true;
} else {
// On desktop, start with sidebar expanded
this.sidebarCollapsed = false;
}
}
private setupUserSubscription(): void {
this.authService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.currentUser = user;
// Section visibility is derived from effective permissions (super_admin → all).
this.showMemberAdminSection = this.memberAdminNavItems.some(item => this.canShow(item));
this.showUserAdminSection = this.userAdminNavItems.some(item => this.canShow(item));
this.showLogsAdminSection = this.logsAdminNavItems.some(item => this.canShow(item));
this.showFinanceSection = this.financeGroups
.some(group => group.items.some(item => this.canShow(item)));
});
}
/** True if a nav item should be shown — items without a permission are always visible. */
public canShow(item: NavItem): boolean {
if (!item.permission) {
return true;
}
return this.permissions.can(item.permission.module, item.permission.action);
}
private setupRouteSubscription(): void {
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
takeUntil(this.destroy$)
)
.subscribe((event: NavigationEnd) => {
this.updateActiveStates(event.url);
this.updatePageTitle();
});
this.updateActiveStates(this.router.url);
this.updatePageTitle();
}
public navigateTo(path: string): void {
this.router.navigate([path]);
this.onNavigationClick();
}
private updateActiveStates(currentUrl: string): void {
const financeItems: NavItem[] = [];
this.financeGroups.forEach(group => financeItems.push(...group.items));
const allItems = [
...this.mainNavItems,
...this.memberAdminNavItems,
...this.userAdminNavItems,
...this.logsAdminNavItems,
...financeItems,
...this.personalNavItems,
];
allItems.forEach(item => (item.active = false));
const activeItem = allItems.find(item => currentUrl.startsWith(item.path));
if (activeItem) {
activeItem.active = true;
}
// Auto-expand the finance group that contains the active page so the
// current location is visible on load/navigation.
const activeGroup = this.financeGroups.find(group =>
group.items.some(item => item.active)
);
if (activeGroup) {
activeGroup.expanded = true;
}
}
public toggleGroup(group: NavGroup): void {
group.expanded = !group.expanded;
}
public matchesSearch(text: string): boolean {
const query = this.searchQuery.trim().toLowerCase();
if (!query) {
return true;
}
return text.toLowerCase().includes(query);
}
public groupHasMatch(group: NavGroup): boolean {
return group.items.some(item => this.matchesSearch(item.text));
}
/** Combined search + permission filter for a single nav item. */
public isVisible(item: NavItem): boolean {
return this.matchesSearch(item.text) && this.canShow(item);
}
/** True if a finance group has at least one visible (permitted + matching) item. */
public groupVisible(group: NavGroup): boolean {
return group.items.some(item => this.isVisible(item));
}
public clearSearch(): void {
this.searchQuery = '';
}
/**
* The unified header reads its title/subtitle/section from the active route's
* `data` (see app.routes.ts). Walk to the deepest activated child so nested
* routes win, and fall back gracefully when a route carries no metadata.
*/
private updatePageTitle(): void {
let route = this.activatedRoute;
while (route.firstChild) {
route = route.firstChild;
}
const data = route.snapshot.data;
this.currentPageTitle = data['title'] ?? 'Dashboard';
this.currentPageTitleZh = data['titleZh'] ?? '';
this.currentSection = data['section'] ?? 'Home';
}
toggleSidebar(): void {
this.sidebarCollapsed = !this.sidebarCollapsed;
}
get mainContentClass(): string {
return this.sidebarCollapsed ? 'main-content sidebar-collapsed' : 'main-content';
}
onSidebarOverlayClick(): void {
if (this.isMobile && !this.sidebarCollapsed) {
this.sidebarCollapsed = true;
}
}
onNavigationClick(): void {
if (this.isMobile) {
this.sidebarCollapsed = true;
}
}
logout(): void {
this.authService.logout();
this.router.navigate(['/login']);
}
getDisplayName(): string {
const member = this.currentUser?.memberInfo;
if (member) {
return `${member.nickName ?? member.firstName_en} ${member.lastName_en}`;
}
return this.currentUser?.email || '';
}
}