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:
Aditya
2026-05-20 12:40:58 +05:30
co-authored by Claude Opus 4.7
parent 325228c514
commit 83ea57ccf0
84 changed files with 10281 additions and 1194 deletions
+243 -133
View File
@@ -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() || '?';
}
}