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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
325228c514
commit
83ea57ccf0
+243
-133
@@ -6,36 +6,47 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||
import {
|
||||
MatTableDataSource,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow
|
||||
} from '@angular/material/table';
|
||||
|
||||
/** rxjs Imports */
|
||||
import { of } from 'rxjs';
|
||||
|
||||
/** Custom Services */
|
||||
import { PopoverService } from '../configuration-wizard/popover/popover.service';
|
||||
import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service';
|
||||
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 component.
|
||||
* 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',
|
||||
@@ -43,143 +54,242 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
styleUrls: ['./users.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTable,
|
||||
MatSort,
|
||||
MatColumnDef,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderCell,
|
||||
MatSortHeader,
|
||||
MatCellDef,
|
||||
MatCell,
|
||||
MatHeaderRowDef,
|
||||
MatHeaderRow,
|
||||
MatRowDef,
|
||||
MatRow,
|
||||
MatPaginator
|
||||
MatTooltip,
|
||||
MatMenuModule
|
||||
]
|
||||
})
|
||||
export class UsersComponent implements OnInit, AfterViewInit {
|
||||
export class UsersComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private configurationWizardService = inject(ConfigurationWizardService);
|
||||
private popoverService = inject(PopoverService);
|
||||
private dialog = inject(MatDialog);
|
||||
private usersService = inject(UsersService);
|
||||
|
||||
/** Users data. */
|
||||
usersData: any;
|
||||
/** Columns to be displayed in users table. */
|
||||
displayedColumns: string[] = [
|
||||
'username',
|
||||
'firstname',
|
||||
'lastname',
|
||||
'email',
|
||||
'mobileNo',
|
||||
'officeName'
|
||||
];
|
||||
/** Data source for users table. */
|
||||
dataSource: MatTableDataSource<any>;
|
||||
/** All users normalised to UserRow for the table. */
|
||||
rows: UserRow[] = [];
|
||||
|
||||
/** Paginator for users table. */
|
||||
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
|
||||
/** Sorter for users table. */
|
||||
@ViewChild(MatSort, { static: true }) sort: MatSort;
|
||||
/** Search text. */
|
||||
search = '';
|
||||
|
||||
/* Reference of create user button */
|
||||
@ViewChild('buttonCreateUser') buttonCreateUser: ElementRef<any>;
|
||||
/* Template for popover create user button */
|
||||
@ViewChild('templateButtonCreateUser') templateButtonCreateUser: TemplateRef<any>;
|
||||
/* Reference of users table */
|
||||
@ViewChild('usersTable') usersTable: ElementRef<any>;
|
||||
/* Template for popover on users table */
|
||||
@ViewChild('templateUsersTable') templateUsersTable: TemplateRef<any>;
|
||||
/** Office filter — empty = all. */
|
||||
officeFilter = '';
|
||||
|
||||
/** 'all' | 'staff' | 'selfservice' role filter. */
|
||||
roleFilter: '' | 'staff' | 'selfservice' = '';
|
||||
|
||||
/**
|
||||
* Retrieves the users data from `resolve`.
|
||||
* @param {ActivatedRoute} route Activated Route.
|
||||
* @param {Router} router Router for navigation.
|
||||
* @param {ConfigurationWizardService} configurationWizardService ConfigurationWizard Service.
|
||||
* @param {PopoverService} popoverService PopoverService.
|
||||
*/
|
||||
constructor() {
|
||||
this.route.data.subscribe((data: { users: any }) => {
|
||||
this.usersData = data.users;
|
||||
this.buildRows(data.users || []);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters data in users table based on passed value.
|
||||
* @param {string} filterValue Value to filter data.
|
||||
*/
|
||||
applyFilter(filterValue: string) {
|
||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
||||
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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the users table.
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.setUsers();
|
||||
// ───── KPIs ─────
|
||||
get totalUsers(): number {
|
||||
return this.rows.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the data source, paginator and sorter for users table.
|
||||
*/
|
||||
setUsers() {
|
||||
this.dataSource = new MatTableDataSource(this.usersData);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
this.dataSource.sort = this.sort;
|
||||
get selfServiceCount(): number {
|
||||
return this.rows.filter((r) => r.isSelfServiceUser).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover function
|
||||
* @param template TemplateRef<any>.
|
||||
* @param target HTMLElement | ElementRef<any>.
|
||||
* @param position String.
|
||||
* @param backdrop Boolean.
|
||||
*/
|
||||
showPopover(
|
||||
template: TemplateRef<any>,
|
||||
target: HTMLElement | ElementRef<any>,
|
||||
position: string,
|
||||
backdrop: boolean
|
||||
): void {
|
||||
setTimeout(() => this.popoverService.open(template, target, position, backdrop, {}), 200);
|
||||
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) ─────
|
||||
/**
|
||||
* To show popover.
|
||||
* Open the read-only details popup. The footer of the details dialog can
|
||||
* chain into edit / delete / change-password without leaving the list.
|
||||
*/
|
||||
ngAfterViewInit() {
|
||||
if (this.configurationWizardService.showUsers) {
|
||||
setTimeout(() => {
|
||||
this.showPopover(this.templateButtonCreateUser, this.buttonCreateUser.nativeElement, 'bottom', true);
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.configurationWizardService.showUsersList) {
|
||||
setTimeout(() => {
|
||||
this.showPopover(this.templateUsersTable, this.usersTable.nativeElement, 'top', true);
|
||||
/** 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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Next Step (Create User Page) Configuration Wizard.
|
||||
*/
|
||||
nextStep() {
|
||||
this.configurationWizardService.showUsers = false;
|
||||
this.configurationWizardService.showUsersList = false;
|
||||
this.configurationWizardService.showUsersForm = true;
|
||||
this.router.navigate(['/appusers/create']);
|
||||
/** 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.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Previous Step (Roles and Permission Page) Configuration Wizard.
|
||||
*/
|
||||
previousStep() {
|
||||
this.configurationWizardService.showUsers = false;
|
||||
this.configurationWizardService.showUsersList = false;
|
||||
this.configurationWizardService.showRolesandPermissionList = true;
|
||||
this.router.navigate(['/system/roles-and-permissions']);
|
||||
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() || '?';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user