Files
cbsnexora/src/app/users/users.component.ts
T
AdityaandClaude Opus 4.7 83ea57ccf0 Client-stepper: pre-fill existing KYC/address proofs in edit, fix identifier saves
Edit-mode now shows the previously selected KYC document type + uploaded file thumbnail
(loads from /clients/{id}/identifiers + /client_identifiers/{id}/documents). Same for
address proof on the India address step. Closes the gap where editing a client showed
an empty file picker even when proof had been uploaded on create.

Identifier-save hardened across all three callsites (create-client, edit-client,
identities-tab.createIdentifierWithDocs) - the KYC chip picker proofId is a CODE
string ('AADHAAR'), but backend ClientIdentifierRequest.documentTypeId is Long, which
Spring rejects with HttpMessageNotReadableException -> 400. resolveDocumentTypeId
helper looks up the code against /kyc/proof-types?type=id and substitutes the numeric
CodeValue id before posting. Skips silently for constitution-specific non-individual
docs that have no CodeValue mapping.

KYC payload normalises panNumber / enterprisePan / gstinNumber / cinNumber via
toUpperCase + whitespace strip so user-typed lowercase or trailing spaces don't trip
the backend regex (validation.msg.CLIENTKYC.panNumber.does.not.match.regexp).

State name match in client-address save now normalises both sides (lowercase, collapse
underscores + spaces) so legacy MADHYA_PRADESH resolves to 'Madhya Pradesh' returned
by api.postalpincode.in. Backed by tenant migration 0297.

Identities-tab resolver flattened - was returning identifiers before the per-row
forkJoin(getClientIdentificationDocuments) completed, so the view rendered empty
documents arrays. Now uses switchMap + forkJoin so the resolver awaits completion.

Also bundles concurrent work-in-progress on letter-mgmt, organization, users,
bank-calendar, shell, nominee/notes/identifier dialogs, and theming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:40:58 +05:30

296 lines
9.8 KiB
TypeScript

/**
* 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, UserDetailsDialogData, UserDetailsDialogResult>(
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, UserFormDialogData, any>(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, UserFormDialogData, any>(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() || '?';
}
}