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>
296 lines
9.8 KiB
TypeScript
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() || '?';
|
|
}
|
|
}
|