Files
cbsnexora/src/app/platform-admin/menu-access/menu-access.component.ts
T
niteshandClaude Sonnet 5 7b77166b19 fix: tenant-config fixes, cashier/share/savings UI bugs, and menu-access hardcoding
- Add Balance Sheet report and disabled-features accounting config
- Point menu-access tenant header and env.js/environment.ts tenant identifier
  at env config instead of hardcoded 'default'/'lincpay' literals
- Surface the real backend error (not a generic "no reference id") when an
  OTP send fails in contact-otp-block
- Fix branch-day "₹null" display bug (null vs undefined) and add a guard
  blocking a branch's first-ever day open before cash denomination migration
- Dynamically size the denomination count input and block re-opening an
  already-open business day from Migrate Cash Denomination
- Lock the denomination-managed cash GL row in Migrate Opening Balances
  (read-only, with a link back to Migrate Cash Denomination)
- Remove duplicate Cancel/Submit action bars on the Share Product and
  Savings Account creation preview steps; align Previous into the same row
- Hide "Apply Collection" on a share account once its advance collection
  is already settled (further purchases go through Apply Additional Shares)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 15:19:21 +05:30

221 lines
8.4 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 } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { environment } from '../../../environments/environment';
interface MenuRow {
code: string;
label: string;
icon: string;
enabled: boolean;
}
interface TenantOption {
identifier: string;
name: string;
}
/**
* Standalone platform-operator screen — deliberately outside the normal login/tenant-selector flow
* every other page in this app goes through. It talks to the backend with a hand-entered API key
* (`X-Api-Key`) instead of a Fineract session, exactly the credential `TenantMenuAccessApiResource`
* requires for its write side and accepts for its reads. Reachable at #/platform-admin/menu-access
* without logging in, on purpose: the real security boundary is the key, checked on the backend, not
* this page's URL — a bank's own super user has no way to obtain that key at all, and without it
* every call here 401s regardless of who is looking at the page.
*
* The key itself is minted once via `POST /v1/apiclients` (super-user session, one-time) and pasted
* in below; this page only remembers it in this browser's localStorage for convenience across visits,
* it is never sent anywhere but the two endpoints below.
*/
@Component({
selector: 'mifosx-platform-menu-access',
standalone: true,
imports: [
CommonModule,
FormsModule,
FaIconComponent
],
templateUrl: './menu-access.component.html',
styleUrl: './menu-access.component.scss'
})
export class PlatformMenuAccessComponent {
private static readonly KEY_STORAGE = 'cbsx_platform_api_key';
/** Every menu code TenantMenuAccessApiResource knows about, with the label and icon shown on this
* bank's own top nav — same icon names as shell.component.html, so a toggle here is recognisable
* as the exact item it controls. Kept in the same order as the nav's two rows. */
private static readonly ALL_MENUS: { code: string; label: string; icon: string }[] = [
{ code: 'MENU_INSTITUTION', label: 'Client Master', icon: 'users-cog' },
{ code: 'SIDEBAR_CFCFRMS', label: 'CFCFRMS', icon: 'user-shield' },
{ code: 'MENU_TASKS', label: 'Approvals', icon: 'check' },
{ code: 'MENU_CASHIER', label: 'Cashier', icon: 'money-bill' },
{ code: 'MENU_CLERK', label: 'Clerk', icon: 'id-card' },
{ code: 'MENU_BRANCH_OPS', label: 'Branch Ops', icon: 'building' },
{ code: 'MENU_CLEARING', label: 'Clearing', icon: 'exchange-alt' },
{ code: 'MENU_DEPOSITS', label: 'Deposits', icon: 'piggy-bank' },
{ code: 'MENU_ADVANCES', label: 'Advances', icon: 'hand-holding-usd' },
{ code: 'MENU_LOAN_ORIGINATION', label: 'Loan Origination', icon: 'clipboard-check' },
{ code: 'MENU_SHARE', label: 'Share', icon: 'coins' },
{ code: 'MENU_NPA', label: 'NPA', icon: 'exclamation-triangle' },
{ code: 'MENU_GENERAL_LEDGER', label: 'General Ledger', icon: 'book-open' },
{ code: 'MENU_SUB_LEDGER', label: 'Sub Ledger', icon: 'book' },
{ code: 'MENU_ACCOUNTING', label: 'Accounting', icon: 'money-bill-alt' },
{ code: 'MENU_CKYC_MGMT', label: 'CKYC', icon: 'fingerprint' },
{ code: 'MENU_INSURANCE', label: 'Insurance', icon: 'shield-alt' },
{ code: 'MENU_DIGITAL', label: 'Digital', icon: 'mobile-alt' },
{ code: 'MENU_HRM', label: 'HRM', icon: 'money-check' },
{ code: 'MENU_ADMIN', label: 'Admin', icon: 'shield-alt' },
{ code: 'MENU_RBI', label: 'RBI', icon: 'university' },
{ code: 'MENU_INVESTMENT', label: 'Investment', icon: 'chart-line' },
{ code: 'MENU_REPORTS', label: 'Reports', icon: 'chart-bar' },
{ code: 'MENU_OTHERS', label: 'Others', icon: 'folder-open' },
{ code: 'MENU_CONFIG_WIZARD', label: 'Reconciliation', icon: 'sync-alt' },
{ code: 'MENU_CHEQUEBOOK', label: 'Cheque Book', icon: 'book' },
{ code: 'MENU_LETTER_MGMT', label: 'Documents Hub', icon: 'envelope' }
];
apiKey = '';
tenants: TenantOption[] = [];
selectedTenant = '';
rows: MenuRow[] = [];
loadingTenants = false;
loadingMenus = false;
saving = false;
error: string | null = null;
savedMessage: string | null = null;
constructor() {
this.apiKey = localStorage.getItem(PlatformMenuAccessComponent.KEY_STORAGE) ?? '';
}
private headers(tenantForFilter?: string): Record<string, string> {
return {
'X-Api-Key': this.apiKey,
// Any real tenant satisfies the platform's tenant-resolution filter; the target tenant for
// read/write is this header's own value, so it is set to whichever tenant is actually in play.
'Fineract-Platform-TenantId':
tenantForFilter || this.selectedTenant || this.tenants[0]?.identifier || environment.fineractPlatformTenantId
};
}
private apiUrl(path: string): string {
return `${environment.serverUrl}${path}`;
}
rememberKey(): void {
localStorage.setItem(PlatformMenuAccessComponent.KEY_STORAGE, this.apiKey);
}
async loadTenants(): Promise<void> {
this.error = null;
this.savedMessage = null;
if (!this.apiKey.trim()) {
this.error = 'Enter the platform API key first.';
return;
}
this.rememberKey();
this.loadingTenants = true;
try {
const res = await fetch(this.apiUrl('/tenant-menu-access/tenants'), {
headers: this.headers(environment.fineractPlatformTenantId)
});
if (!res.ok) {
throw new Error(
res.status === 403 || res.status === 401 ? 'Invalid or inactive API key.' : `Server error (${res.status}).`
);
}
this.tenants = await res.json();
if (this.tenants.length > 0 && !this.selectedTenant) {
this.selectedTenant = this.tenants[0].identifier;
await this.loadMenusForSelectedTenant();
}
} catch (e: any) {
this.error = e?.message ?? 'Could not load the tenant list.';
this.tenants = [];
} finally {
this.loadingTenants = false;
}
}
async loadMenusForSelectedTenant(): Promise<void> {
this.error = null;
this.savedMessage = null;
if (!this.selectedTenant) {
return;
}
this.loadingMenus = true;
try {
const res = await fetch(this.apiUrl('/tenant-menu-access'), { headers: this.headers() });
if (!res.ok) {
throw new Error(`Could not read this tenant's menu list (${res.status}).`);
}
const saved: { menuCode: string; enabled: boolean }[] = await res.json();
const enabledByCode = new Map(
saved.map((r) => [
r.menuCode,
r.enabled
])
);
// A code the tenant has never had a row saved for yet reads as enabled — matches the backend's
// own default (0541 seeds every existing tenant with everything on), so a freshly onboarded
// bank that nobody has touched here shows its real, unrestricted state, not a false "all off".
this.rows = PlatformMenuAccessComponent.ALL_MENUS.map((m) => ({
code: m.code,
label: m.label,
icon: m.icon,
enabled: enabledByCode.has(m.code) ? !!enabledByCode.get(m.code) : true
}));
} catch (e: any) {
this.error = e?.message ?? "Could not load this tenant's menu list.";
this.rows = [];
} finally {
this.loadingMenus = false;
}
}
async save(): Promise<void> {
this.error = null;
this.savedMessage = null;
this.saving = true;
try {
const body = this.rows.map((r) => ({ menuCode: r.code, enabled: r.enabled }));
const res = await fetch(this.apiUrl('/tenant-menu-access'), {
method: 'PUT',
headers: { ...this.headers(), 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
throw new Error(`Save failed (${res.status}).`);
}
this.savedMessage = `Saved for "${this.selectedTenant}".`;
} catch (e: any) {
this.error = e?.message ?? 'Could not save.';
} finally {
this.saving = false;
}
}
setAll(enabled: boolean): void {
this.rows = this.rows.map((r) => ({ ...r, enabled }));
}
get enabledCount(): number {
return this.rows.filter((r) => r.enabled).length;
}
get selectedTenantName(): string {
return this.tenants.find((t) => t.identifier === this.selectedTenant)?.name ?? this.selectedTenant;
}
}