/** * Copyright since 2025 Mifos Initiative * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Component, inject } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltip } from '@angular/material/tooltip'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { UsersService } from './users.service'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component'; import { ErrorDialogComponent } from 'app/shared/error-dialog/error-dialog.component'; import { ChangePasswordDialogComponent } from 'app/shared/change-password-dialog/change-password-dialog.component'; import { UserFormDialogComponent, UserFormDialogData } from './user-form-dialog/user-form-dialog.component'; import { UserDetailsDialogComponent, UserDetailsDialogData, UserDetailsDialogResult } from './user-details-dialog/user-details-dialog.component'; interface UserRow { id: string; username: string; firstname: string; lastname: string; fullName: string; email: string; mobileNo: string; officeName: string; staffDisplayName: string; roles: string[]; isSelfServiceUser: boolean; raw: any; } /** * Users — Nexora redesign. * * Hero header + KPI strip + searchable Nexora table with per-row * View / Edit / Delete actions (with kebab menu for less-common ops). */ @Component({ selector: 'mifosx-users', templateUrl: './users.component.html', styleUrls: ['./users.component.scss'], imports: [ ...STANDALONE_SHARED_IMPORTS, FormsModule, FaIconComponent, MatTooltip, MatMenuModule ] }) export class UsersComponent { private route = inject(ActivatedRoute); private router = inject(Router); private dialog = inject(MatDialog); private usersService = inject(UsersService); /** All users normalised to UserRow for the table. */ rows: UserRow[] = []; /** Search text. */ search = ''; /** Office filter — empty = all. */ officeFilter = ''; /** 'all' | 'staff' | 'selfservice' role filter. */ roleFilter: '' | 'staff' | 'selfservice' = ''; constructor() { this.route.data.subscribe((data: { users: any }) => { this.buildRows(data.users || []); }); } private buildRows(users: any[]): void { this.rows = users.map((u: any) => { const fn = (u.firstname || '').trim(); const ln = (u.lastname || '').trim(); const fullName = [ fn, ln ] .filter(Boolean) .join(' ') || u.username; const roles: string[] = (u.selectedRoles || u.availableRoles || []).map((r: any) => r?.name).filter(Boolean); return { id: String(u.id), username: u.username || '', firstname: fn, lastname: ln, fullName, email: u.email || '', mobileNo: u.mobileNo || '', officeName: u.officeName || '', staffDisplayName: u.staffDisplayName || '', roles, isSelfServiceUser: !!u.isSelfServiceUser, raw: u }; }); } // ───── KPIs ───── get totalUsers(): number { return this.rows.length; } get selfServiceCount(): number { return this.rows.filter((r) => r.isSelfServiceUser).length; } get staffUsersCount(): number { return this.rows.filter((r) => !r.isSelfServiceUser).length; } /** Distinct offices represented in the user list. */ get distinctOfficesCount(): number { return new Set(this.rows.map((r) => r.officeName).filter(Boolean)).size; } /** Office names for the office filter dropdown. */ get officeOptions(): string[] { return Array.from(new Set(this.rows.map((r) => r.officeName).filter(Boolean))).sort(); } // ───── Filtering ───── get filteredRows(): UserRow[] { const q = this.search.trim().toLowerCase(); return this.rows.filter((r) => { if (this.officeFilter && r.officeName !== this.officeFilter) return false; if (this.roleFilter === 'staff' && r.isSelfServiceUser) return false; if (this.roleFilter === 'selfservice' && !r.isSelfServiceUser) return false; if (!q) return true; const hay = `${r.username} ${r.fullName} ${r.email} ${r.mobileNo} ${r.officeName} ${r.roles.join(' ')}`.toLowerCase(); return hay.includes(q); }); } clearFilters(): void { this.search = ''; this.officeFilter = ''; this.roleFilter = ''; } // ───── Row actions (all popup-driven now) ───── /** * Open the read-only details popup. The footer of the details dialog can * chain into edit / delete / change-password without leaving the list. */ viewUser(row: { id: string; username: string }): void { const ref = this.dialog.open( UserDetailsDialogComponent, { data: { userId: row.id }, panelClass: 'udd-panel', autoFocus: false } ); ref.afterClosed().subscribe((action) => { if (action === 'edit') this.editUser(row); else if (action === 'delete') this.deleteUser(row as UserRow); else if (action === 'change-password') this.changePassword(row); }); } /** Open the user form popup in edit mode. */ editUser(row: { id: string }): void { const ref = this.dialog.open(UserFormDialogComponent, { data: { mode: 'edit', userId: row.id }, panelClass: 'ufd-panel', autoFocus: false }); ref.afterClosed().subscribe((result) => { if (result?.ok) { this.showSuccess('User Updated', `Changes to "${result.username}" have been saved.`); this.refreshUsers(); } }); } /** Open the user form popup in create mode. */ createUser(): void { const ref = this.dialog.open(UserFormDialogComponent, { data: { mode: 'create' }, panelClass: 'ufd-panel', autoFocus: false }); ref.afterClosed().subscribe((result) => { if (result?.ok) { this.showSuccess('User Created', `"${result.username}" has been added.`); this.refreshUsers(); } }); } /** Change-password popup — same dialog the legacy view-user uses. */ changePassword(row: { id: string; username?: string }): void { const ref = this.dialog.open(ChangePasswordDialogComponent, { width: '440px' }); ref.afterClosed().subscribe((response: any) => { if (!response?.password || !response?.repeatPassword) return; // Backend expects `firstname` in the payload (Fineract quirk) — pull from // the row's cached payload when we have it, otherwise fetch fresh. const cached = this.rows.find((r) => r.id === row.id); const firstname = cached?.firstname || cached?.fullName || row.username || ''; const body = { password: response.password, repeatPassword: response.repeatPassword, firstname }; this.usersService.changePassword(row.id, body).subscribe({ next: () => this.showSuccess('Password Updated', `New password set for "${row.username || row.id}".`), error: (err: any) => { const apiMsg = err?.error?.errors?.[0]?.defaultUserMessage || 'Please try again.'; this.showError(`Failed to update password. ${apiMsg}`); } }); }); } /** Delete user with confirm + polished outcome dialog. */ deleteUser(row: UserRow): void { const ref = this.dialog.open(DeleteDialogComponent, { data: { deleteContext: `user "${row.username}"` } }); ref.afterClosed().subscribe((response: any) => { if (!response?.delete) return; this.usersService.deleteUser(row.id).subscribe({ next: () => { this.showSuccess('User Removed', `"${row.username}" has been deleted.`); this.refreshUsers(); }, error: (err: any) => { const apiMsg = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.developerMessage || 'Please try again.'; this.showError(`Failed to remove "${row.username}". ${apiMsg}`); } }); }); } /** Copy the user's login name. */ copyUsername(row: UserRow): void { navigator.clipboard ?.writeText(row.username) .then(() => this.showSnackSuccess(`Copied "${row.username}" to clipboard.`)) .catch(() => this.showError('Clipboard access was blocked by the browser.')); } private refreshUsers(): void { this.usersService.getUsers().subscribe((users: any[]) => this.buildRows(users || [])); } // ───── Dialog helpers ───── private showSuccess(title: string, message: string): void { this.dialog.open(SuccessDialogComponent, { data: { title, message, buttonLabel: 'OK' }, panelClass: 'sd-panel' }); } /** Used for lightweight confirmations like "copied to clipboard". */ private showSnackSuccess(message: string): void { this.dialog.open(SuccessDialogComponent, { data: { title: 'Copied', message, buttonLabel: 'OK' }, panelClass: 'sd-panel' }); } private showError(message: string): void { this.dialog.open(ErrorDialogComponent, { data: message }); } // ───── Misc ───── trackRow = (_i: number, r: UserRow) => r.id; initials(row: UserRow): string { const a = (row.firstname || row.username).charAt(0); const b = (row.lastname || '').charAt(0); return `${a}${b}`.toUpperCase() || '?'; } }