- 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>
221 lines
8.4 KiB
TypeScript
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;
|
|
}
|
|
}
|