Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d205672df |
@@ -10,8 +10,7 @@
|
|||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
|
|
||||||
|
|
||||||
/** rxjs Imports */
|
/** rxjs Imports */
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
@@ -199,16 +198,6 @@ export class AccountingService {
|
|||||||
return this.http.get('/journalentries/ledger', { params: httpParams });
|
return this.http.get('/journalentries/ledger', { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The whole transfer a ledger transaction belongs to, when it is one branch's part of a transfer between branches.
|
|
||||||
* An extra, informational lookup: if it fails the screen simply shows no banner, so a failure raises no error popup.
|
|
||||||
*/
|
|
||||||
getInterBranchTransfer(transactionId: string): Observable<any> {
|
|
||||||
return this.http.get(`/accounting/interbranchtransfers/${transactionId}`, {
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} transactionId Transaction ID to be reverted.
|
* @param {string} transactionId Transaction ID to be reverted.
|
||||||
* @param {string} comments Any comments.
|
* @param {string} comments Any comments.
|
||||||
|
|||||||
@@ -139,13 +139,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="limit">
|
|
||||||
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Sanctioned Limit</th>
|
|
||||||
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
|
|
||||||
{{ getCurrencySymbol(row) }} {{ row.overdraftLimit | formatNumber }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="balance">
|
<ng-container matColumnDef="balance">
|
||||||
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
|
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
|
||||||
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
|
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { FormatNumberPipe } from '../../pipes/format-number.pipe';
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { AccountsService } from '../accounts.service';
|
import { AccountsService } from '../accounts.service';
|
||||||
|
|
||||||
export type AccountType = 'savings' | 'current' | 'cc' | 'fixed-deposits' | 'recurring-deposits';
|
export type AccountType = 'savings' | 'current' | 'fixed-deposits' | 'recurring-deposits';
|
||||||
|
|
||||||
interface AccountConfig {
|
interface AccountConfig {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -119,13 +119,6 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
typeLabel: 'CA',
|
typeLabel: 'CA',
|
||||||
typeBadgeClass: 'dtype--ca'
|
typeBadgeClass: 'dtype--ca'
|
||||||
},
|
},
|
||||||
cc: {
|
|
||||||
label: 'CC Accounts',
|
|
||||||
icon: 'hand-holding-usd',
|
|
||||||
accentClass: 'accent--current',
|
|
||||||
typeLabel: 'CC',
|
|
||||||
typeBadgeClass: 'dtype--ca'
|
|
||||||
},
|
|
||||||
'fixed-deposits': {
|
'fixed-deposits': {
|
||||||
label: 'Fixed Deposit Accounts',
|
label: 'Fixed Deposit Accounts',
|
||||||
icon: 'lock',
|
icon: 'lock',
|
||||||
@@ -149,24 +142,6 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => {
|
this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => {
|
||||||
this.accountType = data['accountType'] as AccountType;
|
this.accountType = data['accountType'] as AccountType;
|
||||||
this.displayedColumns = this.accountType === 'cc' ? [
|
|
||||||
'accountNo',
|
|
||||||
'depositType',
|
|
||||||
'clientName',
|
|
||||||
'productName',
|
|
||||||
'limit',
|
|
||||||
'balance',
|
|
||||||
'status',
|
|
||||||
'actions'
|
|
||||||
] : [
|
|
||||||
'accountNo',
|
|
||||||
'depositType',
|
|
||||||
'clientName',
|
|
||||||
'productName',
|
|
||||||
'balance',
|
|
||||||
'status',
|
|
||||||
'actions'
|
|
||||||
];
|
|
||||||
this.pageIndex = 0;
|
this.pageIndex = 0;
|
||||||
this.searchControl.setValue('', { emitEvent: false });
|
this.searchControl.setValue('', { emitEvent: false });
|
||||||
this.loadAccounts();
|
this.loadAccounts();
|
||||||
@@ -208,12 +183,9 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
const toVal = this.toDateControl.value;
|
const toVal = this.toDateControl.value;
|
||||||
const bothDatesSet = !!(fromVal && toVal);
|
const bothDatesSet = !!(fromVal && toVal);
|
||||||
|
|
||||||
// CC accounts come from the server already narrowed to those that allow an overdraft; nothing else is read.
|
|
||||||
const isCc = this.accountType === 'cc';
|
|
||||||
const params: import('../accounts.service').AccountListParams = {
|
const params: import('../accounts.service').AccountListParams = {
|
||||||
offset: isCc ? 0 : this.pageIndex * this.pageSize,
|
offset: this.pageIndex * this.pageSize,
|
||||||
limit: isCc ? 500 : this.pageSize,
|
limit: this.pageSize,
|
||||||
...(isCc && { overdraftOnly: true }),
|
|
||||||
accountNo: this.searchType === 'accountNo' ? search || undefined : undefined,
|
accountNo: this.searchType === 'accountNo' ? search || undefined : undefined,
|
||||||
clientName: this.searchType === 'clientName' ? search || undefined : undefined,
|
clientName: this.searchType === 'clientName' ? search || undefined : undefined,
|
||||||
orderBy: 'id',
|
orderBy: 'id',
|
||||||
@@ -227,7 +199,7 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
const obs$ =
|
const obs$ =
|
||||||
this.accountType === 'savings'
|
this.accountType === 'savings'
|
||||||
? this.accountsService.getSavingsAccounts(params)
|
? this.accountsService.getSavingsAccounts(params)
|
||||||
: this.accountType === 'current' || this.accountType === 'cc'
|
: this.accountType === 'current'
|
||||||
? this.accountsService.getCurrentAccounts(params)
|
? this.accountsService.getCurrentAccounts(params)
|
||||||
: this.accountType === 'fixed-deposits'
|
: this.accountType === 'fixed-deposits'
|
||||||
? this.accountsService.getFixedDepositAccounts(params)
|
? this.accountsService.getFixedDepositAccounts(params)
|
||||||
@@ -267,7 +239,7 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.totalRecords = isCc || Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length);
|
this.totalRecords = Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length);
|
||||||
this.dataSource.data = items;
|
this.dataSource.data = items;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
},
|
},
|
||||||
@@ -335,13 +307,6 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
id,
|
id,
|
||||||
'general'
|
'general'
|
||||||
],
|
],
|
||||||
cc: [
|
|
||||||
'/clients',
|
|
||||||
account.clientId,
|
|
||||||
'current-accounts',
|
|
||||||
id,
|
|
||||||
'general'
|
|
||||||
],
|
|
||||||
'fixed-deposits': [
|
'fixed-deposits': [
|
||||||
'/clients',
|
'/clients',
|
||||||
account.clientId,
|
account.clientId,
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ const routes: Routes = [
|
|||||||
component: AccountsListComponent,
|
component: AccountsListComponent,
|
||||||
data: { accountType: 'current', title: 'Current Accounts', breadcrumb: 'Current Accounts' }
|
data: { accountType: 'current', title: 'Current Accounts', breadcrumb: 'Current Accounts' }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// Cash Credit: current accounts opened on a product that allows an overdraft.
|
|
||||||
path: 'cc',
|
|
||||||
component: AccountsListComponent,
|
|
||||||
data: { accountType: 'cc', title: 'CC Accounts', breadcrumb: 'CC Accounts' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'fixed-deposits',
|
path: 'fixed-deposits',
|
||||||
component: AccountsListComponent,
|
component: AccountsListComponent,
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ export interface AccountListParams {
|
|||||||
fromDate?: string;
|
fromDate?: string;
|
||||||
toDate?: string;
|
toDate?: string;
|
||||||
asOnDate?: string;
|
asOnDate?: string;
|
||||||
/** Current accounts only: just the Cash Credit (overdraft) accounts, filtered by the server. */
|
|
||||||
overdraftOnly?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -57,7 +55,6 @@ export class AccountsService {
|
|||||||
if (params.fromDate) httpParams = httpParams.set('fromDate', params.fromDate);
|
if (params.fromDate) httpParams = httpParams.set('fromDate', params.fromDate);
|
||||||
if (params.toDate) httpParams = httpParams.set('toDate', params.toDate);
|
if (params.toDate) httpParams = httpParams.set('toDate', params.toDate);
|
||||||
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
|
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
|
||||||
if (params.overdraftOnly) httpParams = httpParams.set('overdraftOnly', 'true');
|
|
||||||
return this.http.get('/currentaccounts', { params: httpParams });
|
return this.http.get('/currentaccounts', { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,32 +142,6 @@ export class AmlService {
|
|||||||
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
|
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cash transactions of the threshold or more that no generated report contains yet, shaped like the lines above.
|
|
||||||
* They show on the FIU screen as soon as they are posted; generating the month is what files them.
|
|
||||||
*/
|
|
||||||
getCtrPendingLines(): Observable<any[]> {
|
|
||||||
return this.http.get<any[]>('/aml/ctr-reports/pending-lines');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much came into and went out of each current / Cash Credit account over the last `days` days, with the
|
|
||||||
* customer's declared turnover and the sanctioned limit alongside.
|
|
||||||
*/
|
|
||||||
getCcTracking(days = 30): Observable<any> {
|
|
||||||
return this.http.get<any>('/aml/cc-tracking', { params: new HttpParams().set('days', String(days)) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every credit and debit on one CC account over the last `days` days, with the mode and counterparty each payment
|
|
||||||
* recorded, totalled by mode and by counterparty.
|
|
||||||
*/
|
|
||||||
getCcAccountTrail(accountId: number, days = 30): Observable<any> {
|
|
||||||
return this.http.get<any>(`/aml/cc-tracking/${accountId}/trail`, {
|
|
||||||
params: new HttpParams().set('days', String(days))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** month format: yyyy-MM */
|
/** month format: yyyy-MM */
|
||||||
generateCtrReport(month: string): Observable<any> {
|
generateCtrReport(month: string): Observable<any> {
|
||||||
return this.http.post('/aml/ctr-reports/generate', { month });
|
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||||
|
|||||||
@@ -1,778 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { ApiMappingRow, KeyValue, kv } from './api-management.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads what a vendor's documentation or a Postman / browser / cURL capture gives - in any of these shapes - and turns
|
|
||||||
* it into the pieces of an API: method, URL, path, query parameters, headers, content type, payload, response status,
|
|
||||||
* response headers, response body, and a first guess at the CBS field mapping. Nothing is sent anywhere.
|
|
||||||
*
|
|
||||||
* Understood:
|
|
||||||
* - a cURL command
|
|
||||||
* - a Postman collection or a single Postman request / item (with its saved responses)
|
|
||||||
* - a HAR capture
|
|
||||||
* - an "API definition" object: { method, url | path, query, headers, body, status, ... }
|
|
||||||
* - a bare JSON payload (treated as the request body, or the response body on the response side)
|
|
||||||
* - form text (a=1&b=2) and other raw text
|
|
||||||
*
|
|
||||||
* Credentials found in headers or query strings are never kept: the value is replaced by {{secret}} and the row is
|
|
||||||
* flagged as secret, so a key pasted from a sample cannot end up stored.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ImportedRequest {
|
|
||||||
source: string;
|
|
||||||
method?: string;
|
|
||||||
/** The full URL when the file had one. */
|
|
||||||
url?: string;
|
|
||||||
/** Scheme and host of the URL, when present. */
|
|
||||||
baseUrl?: string;
|
|
||||||
path?: string;
|
|
||||||
queryParams: KeyValue[];
|
|
||||||
headers: KeyValue[];
|
|
||||||
contentType?: string;
|
|
||||||
body?: string;
|
|
||||||
/** A response found in the same file (Postman saved response, HAR entry, definition object). */
|
|
||||||
response?: ImportedResponse;
|
|
||||||
notes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportedResponse {
|
|
||||||
source: string;
|
|
||||||
status?: number;
|
|
||||||
headers: KeyValue[];
|
|
||||||
body?: string;
|
|
||||||
errorBody?: string;
|
|
||||||
notes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECRET_HEADERS =
|
|
||||||
/^(authorization|proxy-authorization|x-api-key|api-key|apikey|authkey|x-auth-token|auth-token|token|x-access-token|cookie|set-cookie|x-hub-signature(-256)?)$/i;
|
|
||||||
const SECRET_QUERY = /^(key|api[-_]?key|apikey|token|access[-_]?token|auth[-_]?key|secret|password|signature)$/i;
|
|
||||||
const SECRET = '{{secret}}';
|
|
||||||
|
|
||||||
// ── small helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function isObject(v: unknown): v is Record<string, any> {
|
|
||||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryJson(text: string): any {
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prettyJsonOrText(text: string): string {
|
|
||||||
const parsed = tryJson(text);
|
|
||||||
return parsed !== undefined && typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bodyToText(body: unknown): string | undefined {
|
|
||||||
if (body === undefined || body === null) return undefined;
|
|
||||||
if (typeof body === 'string') return prettyJsonOrText(body);
|
|
||||||
return JSON.stringify(body, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toKeyValues(source: unknown, kind: 'header' | 'query' | 'plain'): KeyValue[] {
|
|
||||||
const out: KeyValue[] = [];
|
|
||||||
const add = (key: unknown, value: unknown, description = '', disabled = false) => {
|
|
||||||
const k = String(key ?? '').trim();
|
|
||||||
if (!k) return;
|
|
||||||
const secret = (kind === 'header' && SECRET_HEADERS.test(k)) || (kind === 'query' && SECRET_QUERY.test(k));
|
|
||||||
out.push({
|
|
||||||
key: k,
|
|
||||||
value: secret ? SECRET : value === undefined || value === null ? '' : String(value),
|
|
||||||
description,
|
|
||||||
enabled: !disabled,
|
|
||||||
secret
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if (Array.isArray(source)) {
|
|
||||||
for (const item of source) {
|
|
||||||
if (isObject(item))
|
|
||||||
add(item.key ?? item.name, item.value, String(item.description ?? ''), item.disabled === true);
|
|
||||||
}
|
|
||||||
} else if (isObject(source)) {
|
|
||||||
for (const [
|
|
||||||
k,
|
|
||||||
v
|
|
||||||
] of Object.entries(source)) add(k, v);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Splits a URL into its base, path and query without failing on {{placeholders}} or {path} parameters. */
|
|
||||||
function splitUrl(raw: string): { baseUrl?: string; path: string; query: KeyValue[] } {
|
|
||||||
let rest = raw.trim();
|
|
||||||
let baseUrl: string | undefined;
|
|
||||||
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]+)(.*)$/.exec(rest);
|
|
||||||
if (m) {
|
|
||||||
baseUrl = m[1];
|
|
||||||
rest = m[2];
|
|
||||||
}
|
|
||||||
const hash = rest.indexOf('#');
|
|
||||||
if (hash >= 0) rest = rest.slice(0, hash);
|
|
||||||
let queryText = '';
|
|
||||||
const q = rest.indexOf('?');
|
|
||||||
if (q >= 0) {
|
|
||||||
queryText = rest.slice(q + 1);
|
|
||||||
rest = rest.slice(0, q);
|
|
||||||
}
|
|
||||||
const query: KeyValue[] = [];
|
|
||||||
for (const part of queryText.split('&')) {
|
|
||||||
if (!part) continue;
|
|
||||||
const eq = part.indexOf('=');
|
|
||||||
const key = safeDecode(eq >= 0 ? part.slice(0, eq) : part);
|
|
||||||
const value = eq >= 0 ? safeDecode(part.slice(eq + 1)) : '';
|
|
||||||
query.push(...toKeyValues([{ key, value }], 'query'));
|
|
||||||
}
|
|
||||||
return { baseUrl, path: rest || '/', query };
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeDecode(s: string): string {
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(s.replace(/\+/g, ' '));
|
|
||||||
} catch {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectContentType(
|
|
||||||
headers: KeyValue[],
|
|
||||||
body: string | undefined
|
|
||||||
): { contentType?: string; headers: KeyValue[] } {
|
|
||||||
const ct = headers.find((h) => h.key.toLowerCase() === 'content-type');
|
|
||||||
const rest = headers.filter((h) => h.key.toLowerCase() !== 'content-type');
|
|
||||||
if (ct) return { contentType: ct.value.split(';')[0].trim(), headers: rest };
|
|
||||||
if (!body) return { headers: rest };
|
|
||||||
if (tryJson(body) !== undefined) return { contentType: 'application/json', headers: rest };
|
|
||||||
if (/^\s*</.test(body)) return { contentType: 'application/xml', headers: rest };
|
|
||||||
if (/^[^\s=&]+=[^&]*(&[^\s=&]+=[^&]*)*$/.test(body.trim()))
|
|
||||||
return { contentType: 'application/x-www-form-urlencoded', headers: rest };
|
|
||||||
return { contentType: 'text/plain', headers: rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything about a request is derived the same way once method / url / headers / body are known. */
|
|
||||||
function finishRequest(
|
|
||||||
source: string,
|
|
||||||
method: string | undefined,
|
|
||||||
url: string | undefined,
|
|
||||||
pathOnly: string | undefined,
|
|
||||||
query: unknown,
|
|
||||||
headers: KeyValue[],
|
|
||||||
body: string | undefined,
|
|
||||||
contentTypeHint: string | undefined,
|
|
||||||
notes: string[]
|
|
||||||
): ImportedRequest {
|
|
||||||
const parts = url ? splitUrl(url) : pathOnly ? splitUrl(pathOnly) : undefined;
|
|
||||||
const queryParams = [
|
|
||||||
...(parts?.query ?? []),
|
|
||||||
...toKeyValues(query, 'query')
|
|
||||||
];
|
|
||||||
const detected = detectContentType(headers, body);
|
|
||||||
if (queryParams.some((q) => q.secret))
|
|
||||||
notes.push('A credential in the query string was replaced by {{secret}} - set it on the Connection tab.');
|
|
||||||
if (headers.some((h) => h.secret))
|
|
||||||
notes.push('Credentials in the headers were replaced by {{secret}} - set them on the Connection tab.');
|
|
||||||
return {
|
|
||||||
source,
|
|
||||||
method: method ? method.toUpperCase() : body ? 'POST' : undefined,
|
|
||||||
url,
|
|
||||||
baseUrl: parts?.baseUrl,
|
|
||||||
path: parts?.path,
|
|
||||||
queryParams,
|
|
||||||
headers: detected.headers,
|
|
||||||
contentType: contentTypeHint ?? detected.contentType,
|
|
||||||
body,
|
|
||||||
notes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── cURL ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Splits a shell command into words, honouring '…' "…" quotes and \ line continuations. */
|
|
||||||
function shellWords(command: string): string[] {
|
|
||||||
const words: string[] = [];
|
|
||||||
let cur = '';
|
|
||||||
let quote: '"' | "'" | null = null;
|
|
||||||
let started = false;
|
|
||||||
const text = command.replace(/\\\r?\n/g, ' ').trim();
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
const c = text[i];
|
|
||||||
if (quote) {
|
|
||||||
if (c === quote) quote = null;
|
|
||||||
else if (c === '\\' && quote === '"' && i + 1 < text.length) cur += text[++i];
|
|
||||||
else cur += c;
|
|
||||||
} else if (c === '"' || c === "'") {
|
|
||||||
quote = c;
|
|
||||||
started = true;
|
|
||||||
} else if (/\s/.test(c)) {
|
|
||||||
if (started || cur) words.push(cur);
|
|
||||||
cur = '';
|
|
||||||
started = false;
|
|
||||||
} else if (c === '\\' && i + 1 < text.length) {
|
|
||||||
cur += text[++i];
|
|
||||||
} else {
|
|
||||||
cur += c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (started || cur) words.push(cur);
|
|
||||||
return words;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCurl(text: string): ImportedRequest {
|
|
||||||
const words = shellWords(text);
|
|
||||||
const notes: string[] = [];
|
|
||||||
let method: string | undefined;
|
|
||||||
let url: string | undefined;
|
|
||||||
const headers: KeyValue[] = [];
|
|
||||||
const data: string[] = [];
|
|
||||||
let form = false;
|
|
||||||
let getMode = false;
|
|
||||||
for (let i = 0; i < words.length; i++) {
|
|
||||||
const w = words[i];
|
|
||||||
const next = () => words[++i] ?? '';
|
|
||||||
if (w === 'curl') continue;
|
|
||||||
if (w === '-X' || w === '--request') method = next();
|
|
||||||
else if (w === '-H' || w === '--header') {
|
|
||||||
const h = next();
|
|
||||||
const c = h.indexOf(':');
|
|
||||||
if (c > 0) headers.push(...toKeyValues([{ key: h.slice(0, c), value: h.slice(c + 1).trim() }], 'header'));
|
|
||||||
} else if ([
|
|
||||||
'-d',
|
|
||||||
'--data',
|
|
||||||
'--data-raw',
|
|
||||||
'--data-binary',
|
|
||||||
'--data-ascii'
|
|
||||||
].includes(w)) data.push(next());
|
|
||||||
else if (w === '--data-urlencode') {
|
|
||||||
data.push(next());
|
|
||||||
form = true;
|
|
||||||
} else if (w === '-F' || w === '--form') {
|
|
||||||
data.push(next());
|
|
||||||
form = true;
|
|
||||||
notes.push('Multipart form fields were read as name=value pairs.');
|
|
||||||
} else if (w === '--url') url = next();
|
|
||||||
else if (w === '-G' || w === '--get') getMode = true;
|
|
||||||
else if (w === '-u' || w === '--user') {
|
|
||||||
next();
|
|
||||||
headers.push({
|
|
||||||
key: 'Authorization',
|
|
||||||
value: SECRET,
|
|
||||||
description: 'Basic authentication from the cURL -u option',
|
|
||||||
enabled: true,
|
|
||||||
secret: true
|
|
||||||
});
|
|
||||||
} else if (w.startsWith('-')) {
|
|
||||||
// Flags that take a value we do not need.
|
|
||||||
if ([
|
|
||||||
'-o',
|
|
||||||
'--output',
|
|
||||||
'-A',
|
|
||||||
'--user-agent',
|
|
||||||
'-e',
|
|
||||||
'--referer',
|
|
||||||
'--connect-timeout',
|
|
||||||
'-m',
|
|
||||||
'--max-time',
|
|
||||||
'--proxy',
|
|
||||||
'-x'
|
|
||||||
].includes(w)) next();
|
|
||||||
} else if (!url && /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(w)) url = w;
|
|
||||||
else if (!url && /^[\w.-]+(:\d+)?\//.test(w)) url = w;
|
|
||||||
}
|
|
||||||
let body = data.length ? data.join('&') : undefined;
|
|
||||||
if (body && getMode) {
|
|
||||||
url = url + (url?.includes('?') ? '&' : '?') + body;
|
|
||||||
body = undefined;
|
|
||||||
}
|
|
||||||
return finishRequest(
|
|
||||||
'cURL command',
|
|
||||||
method ?? (getMode ? 'GET' : body ? 'POST' : 'GET'),
|
|
||||||
url,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
headers,
|
|
||||||
body ? prettyJsonOrText(body) : undefined,
|
|
||||||
form && !headers.some((h) => h.key.toLowerCase() === 'content-type')
|
|
||||||
? 'application/x-www-form-urlencoded'
|
|
||||||
: undefined,
|
|
||||||
notes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Postman / HAR / definition object ───────────────────────────────────────
|
|
||||||
|
|
||||||
function findPostmanItem(node: any): any {
|
|
||||||
if (!node) return undefined;
|
|
||||||
if (node.request) return node;
|
|
||||||
if (Array.isArray(node.item)) {
|
|
||||||
for (const child of node.item) {
|
|
||||||
const found = findPostmanItem(child);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function postmanUrl(url: any): string | undefined {
|
|
||||||
if (!url) return undefined;
|
|
||||||
if (typeof url === 'string') return url;
|
|
||||||
if (typeof url.raw === 'string') return url.raw;
|
|
||||||
const host = Array.isArray(url.host) ? url.host.join('.') : url.host;
|
|
||||||
const path = Array.isArray(url.path) ? url.path.join('/') : url.path;
|
|
||||||
return `${url.protocol ? url.protocol + '://' : ''}${host ?? ''}/${path ?? ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function postmanResponse(r: any): ImportedResponse {
|
|
||||||
return {
|
|
||||||
source: 'Postman saved response',
|
|
||||||
status: typeof r?.code === 'number' ? r.code : undefined,
|
|
||||||
headers: toKeyValues(r?.header, 'header'),
|
|
||||||
body: bodyToText(r?.body),
|
|
||||||
notes: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromPostman(item: any): ImportedRequest {
|
|
||||||
const req = item.request;
|
|
||||||
const notes: string[] = [];
|
|
||||||
let body: string | undefined;
|
|
||||||
let contentType: string | undefined;
|
|
||||||
const b = req?.body;
|
|
||||||
if (b?.mode === 'raw') {
|
|
||||||
body = bodyToText(b.raw);
|
|
||||||
const lang = b.options?.raw?.language;
|
|
||||||
if (lang === 'json') contentType = 'application/json';
|
|
||||||
} else if (b?.mode === 'urlencoded') {
|
|
||||||
body = (b.urlencoded ?? [])
|
|
||||||
.filter((p: any) => !p.disabled)
|
|
||||||
.map((p: any) => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value ?? '')}`)
|
|
||||||
.join('&');
|
|
||||||
contentType = 'application/x-www-form-urlencoded';
|
|
||||||
} else if (b?.mode === 'formdata') {
|
|
||||||
body = (b.formdata ?? []).map((p: any) => `${p.key}=${p.value ?? ''}`).join('&');
|
|
||||||
contentType = 'multipart/form-data';
|
|
||||||
}
|
|
||||||
const request = finishRequest(
|
|
||||||
'Postman request',
|
|
||||||
typeof req?.method === 'string' ? req.method : undefined,
|
|
||||||
postmanUrl(req?.url),
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
toKeyValues(req?.header, 'header'),
|
|
||||||
body,
|
|
||||||
contentType,
|
|
||||||
notes
|
|
||||||
);
|
|
||||||
if (Array.isArray(item.response) && item.response.length) request.response = postmanResponse(item.response[0]);
|
|
||||||
if (req?.auth)
|
|
||||||
notes.push('The request had an auth section; it was not read. Set authentication on the Connection tab.');
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromHar(har: any): ImportedRequest {
|
|
||||||
const entry = har.log.entries[0];
|
|
||||||
const r = entry.request ?? {};
|
|
||||||
const body = r.postData?.text ? prettyJsonOrText(r.postData.text) : undefined;
|
|
||||||
const request = finishRequest(
|
|
||||||
'HAR capture',
|
|
||||||
r.method,
|
|
||||||
r.url,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
toKeyValues(r.headers, 'header'),
|
|
||||||
body,
|
|
||||||
typeof r.postData?.mimeType === 'string' ? r.postData.mimeType.split(';')[0] : undefined,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const res = entry.response;
|
|
||||||
if (res) {
|
|
||||||
request.response = {
|
|
||||||
source: 'HAR capture',
|
|
||||||
status: typeof res.status === 'number' ? res.status : undefined,
|
|
||||||
headers: toKeyValues(res.headers, 'header'),
|
|
||||||
body: res.content?.text ? prettyJsonOrText(res.content.text) : undefined,
|
|
||||||
notes: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
function definitionResponse(def: any): ImportedResponse | undefined {
|
|
||||||
const r = def.response ?? def.responseDefinition;
|
|
||||||
const status = def.status ?? def.statusCode ?? def.successCode ?? r?.status ?? r?.statusCode ?? r?.code;
|
|
||||||
const headers = def.responseHeaders ?? r?.headers;
|
|
||||||
const body = def.responseBody ?? (isObject(r) ? (r.body ?? r.data) : r);
|
|
||||||
const errorBody = def.errorBody ?? def.errorResponse ?? r?.errorBody;
|
|
||||||
if (status === undefined && headers === undefined && body === undefined && errorBody === undefined) return undefined;
|
|
||||||
return {
|
|
||||||
source: 'API definition',
|
|
||||||
status: typeof status === 'number' ? status : status ? Number(status) || undefined : undefined,
|
|
||||||
headers: toKeyValues(headers, 'header'),
|
|
||||||
body: bodyToText(body),
|
|
||||||
errorBody: bodyToText(errorBody),
|
|
||||||
notes: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromDefinition(def: any): ImportedRequest {
|
|
||||||
const body = def.body ?? def.payload ?? def.data ?? def.requestBody ?? def.request;
|
|
||||||
const bodyText = isObject(body) && ('method' in body || 'url' in body) ? undefined : bodyToText(body);
|
|
||||||
const url = def.url ?? def.endpoint ?? def.uri;
|
|
||||||
const request = finishRequest(
|
|
||||||
'API definition',
|
|
||||||
def.method ?? def.httpMethod,
|
|
||||||
typeof url === 'string' ? url : undefined,
|
|
||||||
typeof def.path === 'string' ? def.path : undefined,
|
|
||||||
def.query ?? def.queryParams ?? def.params,
|
|
||||||
toKeyValues(def.headers, 'header'),
|
|
||||||
bodyText,
|
|
||||||
typeof def.contentType === 'string' ? def.contentType : undefined,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
request.response = definitionResponse(def);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── public: request side ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function parseRequestText(text: string): ImportedRequest {
|
|
||||||
const t = (text ?? '').trim();
|
|
||||||
if (!t) throw new Error('Nothing to read - paste the request or choose a file.');
|
|
||||||
if (/^curl\b/i.test(t)) return parseCurl(t);
|
|
||||||
|
|
||||||
const parsed = tryJson(t);
|
|
||||||
if (parsed === undefined) {
|
|
||||||
// Not JSON: a cURL command in the middle of text, form data, or other raw text.
|
|
||||||
const curl = /curl\b[\s\S]*/i.exec(t);
|
|
||||||
if (curl && /https?:\/\//.test(curl[0])) return parseCurl(curl[0]);
|
|
||||||
const detected = detectContentType([], t);
|
|
||||||
return finishRequest('Raw text', undefined, undefined, undefined, undefined, [], t, detected.contentType, [
|
|
||||||
'The text is not JSON; it was kept as the request payload.'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if (isObject(parsed) && isObject(parsed.log) && Array.isArray(parsed.log.entries) && parsed.log.entries.length)
|
|
||||||
return fromHar(parsed);
|
|
||||||
if (isObject(parsed) && (parsed.item || parsed.info?.schema)) {
|
|
||||||
const item = findPostmanItem(parsed);
|
|
||||||
if (!item) throw new Error('The Postman collection has no request in it.');
|
|
||||||
return fromPostman(item);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
isObject(parsed) &&
|
|
||||||
isObject(parsed.request) &&
|
|
||||||
(parsed.request.url || parsed.request.method || parsed.request.header)
|
|
||||||
)
|
|
||||||
return fromPostman(parsed);
|
|
||||||
if (isObject(parsed) && [
|
|
||||||
'method',
|
|
||||||
'httpMethod',
|
|
||||||
'url',
|
|
||||||
'endpoint',
|
|
||||||
'uri',
|
|
||||||
'headers',
|
|
||||||
'queryParams',
|
|
||||||
'requestBody',
|
|
||||||
'payload'
|
|
||||||
].some((k) => k in parsed) && !('statuses' in parsed)) {
|
|
||||||
return fromDefinition(parsed);
|
|
||||||
}
|
|
||||||
// A bare JSON payload.
|
|
||||||
return finishRequest(
|
|
||||||
'JSON payload',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
[],
|
|
||||||
JSON.stringify(parsed, null, 2),
|
|
||||||
'application/json',
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── public: response side ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function parseResponseText(text: string): ImportedResponse {
|
|
||||||
const t = (text ?? '').trim();
|
|
||||||
if (!t) throw new Error('Nothing to read - paste the response or choose a file.');
|
|
||||||
const parsed = tryJson(t);
|
|
||||||
if (parsed === undefined) {
|
|
||||||
return {
|
|
||||||
source: 'Raw text',
|
|
||||||
headers: [],
|
|
||||||
body: t,
|
|
||||||
notes: ['The text is not JSON; it was kept as the response body.']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (isObject(parsed) && isObject(parsed.log) && Array.isArray(parsed.log.entries) && parsed.log.entries.length) {
|
|
||||||
return (
|
|
||||||
fromHar(parsed).response ?? { source: 'HAR capture', headers: [], notes: ['The HAR entry has no response.'] }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isObject(parsed) && (parsed.item || parsed.info?.schema)) {
|
|
||||||
const item = findPostmanItem(parsed);
|
|
||||||
const res = item ? fromPostman(item).response : undefined;
|
|
||||||
if (!res) throw new Error('The Postman collection has no saved response.');
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
if (isObject(parsed) && Array.isArray(parsed.response) && parsed.response.length && (parsed.request || parsed.name)) {
|
|
||||||
return postmanResponse(parsed.response[0]);
|
|
||||||
}
|
|
||||||
if (isObject(parsed) && [
|
|
||||||
'status',
|
|
||||||
'statusCode',
|
|
||||||
'headers',
|
|
||||||
'responseHeaders',
|
|
||||||
'responseBody',
|
|
||||||
'errorBody'
|
|
||||||
].some(
|
|
||||||
(k) => k in parsed
|
|
||||||
) && ('body' in parsed || 'responseBody' in parsed || 'headers' in parsed || 'responseHeaders' in parsed || 'errorBody' in parsed || 'data' in parsed)) {
|
|
||||||
const r = definitionResponse({
|
|
||||||
status: parsed.status ?? parsed.statusCode,
|
|
||||||
responseHeaders: parsed.responseHeaders ?? parsed.headers,
|
|
||||||
responseBody: parsed.responseBody ?? parsed.body ?? parsed.data,
|
|
||||||
errorBody: parsed.errorBody
|
|
||||||
});
|
|
||||||
if (r) return r;
|
|
||||||
}
|
|
||||||
return { source: 'JSON body', headers: [], body: JSON.stringify(parsed, null, 2), notes: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── JSON → mapping rows ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface JsonLeaf {
|
|
||||||
path: string;
|
|
||||||
value: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every value in a JSON document with its path: `a.b[0].c`. Only the first item of an array is walked. */
|
|
||||||
export function flatten(json: unknown, prefix = ''): JsonLeaf[] {
|
|
||||||
const out: JsonLeaf[] = [];
|
|
||||||
if (Array.isArray(json)) {
|
|
||||||
if (json.length) out.push(...flatten(json[0], `${prefix}[0]`));
|
|
||||||
} else if (isObject(json)) {
|
|
||||||
for (const [
|
|
||||||
k,
|
|
||||||
v
|
|
||||||
] of Object.entries(json)) {
|
|
||||||
out.push(...flatten(v, prefix ? `${prefix}.${k}` : k));
|
|
||||||
}
|
|
||||||
} else if (prefix) {
|
|
||||||
out.push({ path: prefix, value: json });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lastKey(path: string): string {
|
|
||||||
const m = /([A-Za-z0-9_$-]+)(\[\d+\])*$/.exec(path);
|
|
||||||
return m ? m[1] : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
||||||
|
|
||||||
const REQUEST_GUESS: [RegExp, string, boolean][] = [
|
|
||||||
[
|
|
||||||
/^(mobile|mobiles|mobileno|mobilenumber|msisdn|phone|phoneno|phonenumber|to|destination|recipient|recipients|number|contact)$/,
|
|
||||||
'customer.mobile',
|
|
||||||
true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(templateid|template|templatename|dlttemplateid|flowid)$/,
|
|
||||||
'message.templateId',
|
|
||||||
true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(message|text|body|content|sms|msg|smstext|var1|messagetext)$/,
|
|
||||||
'message.text',
|
|
||||||
true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(sender|senderid|from|source|shortcode)$/,
|
|
||||||
'bank.senderId',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(email|emailid|mail|emailaddress)$/,
|
|
||||||
'customer.email',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(customername|fullname|firstname|name)$/,
|
|
||||||
'customer.name',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(otp|code|pin)$/,
|
|
||||||
'message.otp',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(pan|pannumber)$/,
|
|
||||||
'customer.pan',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(aadhaar|aadhar|aadhaarnumber)$/,
|
|
||||||
'customer.aadhaar',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(dob|dateofbirth|birthdate)$/,
|
|
||||||
'customer.dob',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(membercode|memberid|memberreference)$/,
|
|
||||||
'bank.memberCode',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(accountno|accountnumber|acno|account)$/,
|
|
||||||
'account.number',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(cif|customerid|clientid|cifno)$/,
|
|
||||||
'customer.cif',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(amount|amt|txnamount|transactionamount)$/,
|
|
||||||
'txn.amount',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(txnid|transactionid|transactionreference|txnref)$/,
|
|
||||||
'txn.reference',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(txndate|transactiondate)$/,
|
|
||||||
'txn.date',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(fromdate|startdate|datefrom)$/,
|
|
||||||
'request.fromDate',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(todate|enddate|dateto)$/,
|
|
||||||
'request.toDate',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(reference|referenceno|referencenumber|ref|requestid|clientreference|correlationid)$/,
|
|
||||||
'request.reference',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(tenantidentifier|tenantid)$/,
|
|
||||||
'tenant.identifier',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(providerid)$/,
|
|
||||||
'sms.providerId',
|
|
||||||
false
|
|
||||||
]
|
|
||||||
];
|
|
||||||
|
|
||||||
function guessTransform(cbsField: string, value: unknown): string {
|
|
||||||
if (cbsField === 'customer.mobile') return 'DIGITS_ONLY';
|
|
||||||
if (cbsField === 'customer.email') return 'LOWER';
|
|
||||||
if (cbsField === 'bank.senderId') return 'UPPER';
|
|
||||||
if (cbsField === 'txn.amount') return 'NUMBER';
|
|
||||||
if (typeof value === 'boolean') return 'BOOLEAN';
|
|
||||||
if (typeof value === 'number') return 'NUMBER';
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
if (/^\d{8}$/.test(value) && cbsField.includes('Date')) return 'DATE_DDMMYYYY';
|
|
||||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return 'DATE_YYYY_MM_DD';
|
|
||||||
}
|
|
||||||
return 'NONE';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A first mapping for a request payload: each value that looks like a known CBS field (mobile, message, template,
|
|
||||||
* sender ...) becomes "CBS field → path"; every other value becomes a fixed value at its path, so the whole payload is
|
|
||||||
* reproduced. Review and adjust.
|
|
||||||
*/
|
|
||||||
export function guessRequestRows(bodyText: string): ApiMappingRow[] {
|
|
||||||
const parsed = tryJson(bodyText);
|
|
||||||
if (parsed === undefined || typeof parsed !== 'object' || parsed === null) return [];
|
|
||||||
const rows: ApiMappingRow[] = [];
|
|
||||||
const used = new Set<string>();
|
|
||||||
for (const leaf of flatten(parsed)) {
|
|
||||||
const key = norm(lastKey(leaf.path));
|
|
||||||
const hit = REQUEST_GUESS.find(([re]) => re.test(key));
|
|
||||||
if (hit && !used.has(hit[1])) {
|
|
||||||
used.add(hit[1]);
|
|
||||||
rows.push({
|
|
||||||
from: hit[1],
|
|
||||||
to: leaf.path,
|
|
||||||
transform: guessTransform(hit[1], leaf.value),
|
|
||||||
defaultValue: '',
|
|
||||||
mandatory: hit[2]
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
rows.push({
|
|
||||||
from: '',
|
|
||||||
to: leaf.path,
|
|
||||||
transform: guessTransform('', leaf.value),
|
|
||||||
defaultValue: leaf.value === null ? '' : String(leaf.value),
|
|
||||||
mandatory: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows.filter((r) => r.from || r.defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A first mapping for a response body: every value at its path → a CBS field named after the vendor and the key
|
|
||||||
* (`sms.messageId`). Ids / references are marked required.
|
|
||||||
*/
|
|
||||||
export function guessResponseRows(bodyText: string, vendorCode: string): ApiMappingRow[] {
|
|
||||||
const parsed = tryJson(bodyText);
|
|
||||||
if (parsed === undefined || typeof parsed !== 'object' || parsed === null) return [];
|
|
||||||
const prefix = vendorCode.toLowerCase().split('_')[0] || 'vendor';
|
|
||||||
const rows: ApiMappingRow[] = [];
|
|
||||||
const used = new Set<string>();
|
|
||||||
for (const leaf of flatten(parsed)) {
|
|
||||||
const key = lastKey(leaf.path);
|
|
||||||
const n = norm(key);
|
|
||||||
let target: string;
|
|
||||||
if (/^(messageid|msgid|id|reference|requestid|referenceid|messageid)$/.test(n)) target = 'reference';
|
|
||||||
else if (/^(status|state|result|type)$/.test(n)) target = 'status';
|
|
||||||
else if (/^(error|errormessage|errormsg|reason|detail|description)$/.test(n)) target = 'error';
|
|
||||||
else target = key.replace(/[^A-Za-z0-9]+(.)?/g, (_m, c: string) => (c ? c.toUpperCase() : ''));
|
|
||||||
let to = `${prefix}.${target}`;
|
|
||||||
for (let i = 2; used.has(to); i++) to = `${prefix}.${target}${i}`;
|
|
||||||
used.add(to);
|
|
||||||
rows.push({
|
|
||||||
from: leaf.path,
|
|
||||||
to,
|
|
||||||
transform: target === 'status' ? 'UPPER' : guessTransform('', leaf.value),
|
|
||||||
defaultValue: '',
|
|
||||||
mandatory: target === 'reference'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function secretNote(rows: KeyValue[]): string | null {
|
|
||||||
const n = rows.filter((r) => r.secret).length;
|
|
||||||
return n ? `${n} credential value(s) were hidden.` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { kv };
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
@if (!rows.length) {
|
|
||||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">{{ emptyText }}</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 34px">On</th>
|
|
||||||
<th>{{ keyLabel }}</th>
|
|
||||||
<th>{{ valueLabel }}</th>
|
|
||||||
<th>Note</th>
|
|
||||||
<th style="width: 60px">Secret</th>
|
|
||||||
@if (!fixedKeys) {
|
|
||||||
<th style="width: 40px"></th>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (r of rows; track $index; let i = $index) {
|
|
||||||
<tr>
|
|
||||||
<td style="text-align: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="r.enabled" style="width: auto" />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input class="cic-mono" [(ngModel)]="r.key" [placeholder]="keyPlaceholder" [readonly]="fixedKeys" />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
class="cic-mono"
|
|
||||||
[type]="r.secret ? 'password' : 'text'"
|
|
||||||
[(ngModel)]="r.value"
|
|
||||||
[placeholder]="valuePlaceholder"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td><input [(ngModel)]="r.description" /></td>
|
|
||||||
<td style="text-align: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="r.secret" style="width: auto" title="Show the value masked" />
|
|
||||||
</td>
|
|
||||||
@if (!fixedKeys) {
|
|
||||||
<td>
|
|
||||||
<button class="cic-btn sm danger" (click)="remove(i)">✕</button>
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (!fixedKeys) {
|
|
||||||
<button class="cic-btn sm" style="margin-top: 8px" (click)="add()">{{ addLabel }}</button>
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { CommonModule } from '@angular/common';
|
|
||||||
import { Component, Input } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
|
|
||||||
import { KeyValue } from '../api-management.service';
|
|
||||||
|
|
||||||
/** An editable table of name / value pairs: headers, query parameters, path parameters. Edits the array in place. */
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-api-key-value-editor',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule
|
|
||||||
],
|
|
||||||
templateUrl: './api-key-value-editor.component.html',
|
|
||||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class ApiKeyValueEditorComponent {
|
|
||||||
@Input() rows: KeyValue[] = [];
|
|
||||||
@Input() keyLabel = 'Name';
|
|
||||||
@Input() valueLabel = 'Value';
|
|
||||||
@Input() keyPlaceholder = '';
|
|
||||||
@Input() valuePlaceholder = '';
|
|
||||||
@Input() addLabel = '+ Add';
|
|
||||||
/** Path parameters come from the path itself; the names cannot be edited or removed. */
|
|
||||||
@Input() fixedKeys = false;
|
|
||||||
@Input() emptyText = 'None.';
|
|
||||||
|
|
||||||
add(): void {
|
|
||||||
this.rows.push({ key: '', value: '', description: '', enabled: true, secret: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(i: number): void {
|
|
||||||
this.rows.splice(i, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
@if (log) {
|
|
||||||
<div class="cic-modal-backdrop" (click)="closed.emit()">
|
|
||||||
<div class="cic-modal" style="width: min(1100px, 100%)" (click)="$event.stopPropagation()">
|
|
||||||
<h3>
|
|
||||||
{{ log.vendorName }} · {{ log.apiName }}
|
|
||||||
<span class="cic-badge" [class.brand]="log.direction === 'IN'" [class.ok]="log.direction === 'OUT'">{{
|
|
||||||
log.direction
|
|
||||||
}}</span>
|
|
||||||
<span class="cic-badge" [class.good]="log.result === 'SUCCESS'" [class.bad]="log.result === 'FAILED'">{{
|
|
||||||
log.result
|
|
||||||
}}</span>
|
|
||||||
</h3>
|
|
||||||
<dl class="cic-kv" style="grid-template-columns: 120px minmax(0, 1fr); margin-bottom: 12px">
|
|
||||||
<dt>Reference</dt>
|
|
||||||
<dd class="cic-mono">{{ log.reference }}</dd>
|
|
||||||
<dt>Call</dt>
|
|
||||||
<dd class="cic-mono">{{ log.method }} {{ log.url }}</dd>
|
|
||||||
<dt>Result</dt>
|
|
||||||
<dd>HTTP {{ log.httpStatus }} in {{ log.durationMs }} ms · {{ log.time | slice: 0 : 16 }}</dd>
|
|
||||||
@if (log.error) {
|
|
||||||
<dt>Error</dt>
|
|
||||||
<dd class="tone-bad">{{ log.error }}</dd>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
<div class="cic-split">
|
|
||||||
<div>
|
|
||||||
<h4 style="margin: 0 0 6px; font-size: 13px">
|
|
||||||
{{ log.direction === 'OUT' ? 'Request headers' : 'Headers received / sent' }}
|
|
||||||
<span class="muted" style="font-weight: 400">(credentials masked)</span>
|
|
||||||
</h4>
|
|
||||||
<pre class="cic-code" style="max-height: 160px">{{ log.requestHeaders }}</pre>
|
|
||||||
<h4 style="margin: 12px 0 6px; font-size: 13px">Request payload</h4>
|
|
||||||
<pre class="cic-code">{{ log.request || '(empty)' }}</pre>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 style="margin: 0 0 6px; font-size: 13px">Response headers</h4>
|
|
||||||
<pre class="cic-code" style="max-height: 160px">{{ log.responseHeaders }}</pre>
|
|
||||||
<h4 style="margin: 12px 0 6px; font-size: 13px">Response body</h4>
|
|
||||||
<pre class="cic-code">{{ log.response || '(empty)' }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="foot">
|
|
||||||
<button class="cic-btn" (click)="closed.emit()">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { CommonModule } from '@angular/common';
|
|
||||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
|
||||||
|
|
||||||
import { ApiLog } from '../api-management.service';
|
|
||||||
|
|
||||||
/** One API call: what was sent and what came back. */
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-api-log-detail',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule],
|
|
||||||
templateUrl: './api-log-detail.component.html',
|
|
||||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class ApiLogDetailComponent {
|
|
||||||
@Input() log: ApiLog | null = null;
|
|
||||||
@Output() closed = new EventEmitter<void>();
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="cic-page">
|
|
||||||
<div class="cic-head">
|
|
||||||
<div>
|
|
||||||
<h1>API Call Logs</h1>
|
|
||||||
<p>Every API call the CBS made (OUT) or received (IN), across all vendors, with its request and response.</p>
|
|
||||||
</div>
|
|
||||||
<div class="cic-actions">
|
|
||||||
<a class="cic-btn" routerLink="/api-management/vendors">← All vendors</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cic-card">
|
|
||||||
<div class="cic-grid cols-4" style="align-items: end">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Vendor</label>
|
|
||||||
<select [(ngModel)]="filter.vendorId">
|
|
||||||
<option value="">All vendors</option>
|
|
||||||
@for (v of vendors; track v.id) {
|
|
||||||
<option [value]="v.id">{{ v.name }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Direction</label>
|
|
||||||
<select [(ngModel)]="filter.direction">
|
|
||||||
<option value="">IN and OUT</option>
|
|
||||||
<option value="IN">IN — received</option>
|
|
||||||
<option value="OUT">OUT — sent</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Result</label>
|
|
||||||
<select [(ngModel)]="filter.result">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="SUCCESS">Success</option>
|
|
||||||
<option value="FAILED">Failed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Search</label>
|
|
||||||
<input [(ngModel)]="filter.search" (keyup.enter)="apply()" placeholder="Reference, API, path" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-actions" style="margin-top: 12px; justify-content: flex-end">
|
|
||||||
<button class="cic-btn" (click)="reset()">Reset</button>
|
|
||||||
<button class="cic-btn primary" (click)="apply()">Apply</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>
|
|
||||||
{{ total }} call(s)
|
|
||||||
<span class="cic-card-sub">
|
|
||||||
<span class="tone-good">{{ success }} success</span> ·
|
|
||||||
<span [class.tone-bad]="failed > 0">{{ failed }} failed</span>
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
@if (loading && !items.length) {
|
|
||||||
<div class="cic-empty">Loading…</div>
|
|
||||||
} @else if (!items.length) {
|
|
||||||
<div class="cic-empty">No call matches the filter.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>Vendor</th>
|
|
||||||
<th>API</th>
|
|
||||||
<th>Direction</th>
|
|
||||||
<th>Call</th>
|
|
||||||
<th>HTTP</th>
|
|
||||||
<th class="num">ms</th>
|
|
||||||
<th>Result</th>
|
|
||||||
<th>Reference</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (l of items; track l.id) {
|
|
||||||
<tr class="clickable" (click)="openLog = l">
|
|
||||||
<td>{{ l.time | slice: 0 : 16 }}</td>
|
|
||||||
<td>{{ l.vendorName }}</td>
|
|
||||||
<td>{{ l.apiName }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class.brand]="l.direction === 'IN'" [class.ok]="l.direction === 'OUT'">{{
|
|
||||||
l.direction
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px">{{ l.method }} {{ l.path }}</td>
|
|
||||||
<td>{{ l.httpStatus }}</td>
|
|
||||||
<td class="num">{{ l.durationMs }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class.good]="l.result === 'SUCCESS'" [class.bad]="l.result === 'FAILED'">{{
|
|
||||||
l.result
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px">{{ l.reference }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="cic-pager">
|
|
||||||
<span>{{ offset + 1 }}–{{ offset + items.length }} of {{ total }}</span>
|
|
||||||
<span class="cic-actions">
|
|
||||||
<button class="cic-btn sm" (click)="page(-1)" [disabled]="offset === 0">Previous</button>
|
|
||||||
<button class="cic-btn sm" (click)="page(1)" [disabled]="offset + limit >= total">Next</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<mifosx-api-log-detail [log]="openLog" (closed)="openLog = null"></mifosx-api-log-detail>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { CommonModule } from '@angular/common';
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { ApiLogDetailComponent } from '../api-log-detail/api-log-detail.component';
|
|
||||||
import { ApiLog, ApiManagementService, ApiVendor } from '../api-management.service';
|
|
||||||
|
|
||||||
/** Every API call, IN and OUT, across all vendors, with its request and response. */
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-api-logs',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
RouterModule,
|
|
||||||
ApiLogDetailComponent
|
|
||||||
],
|
|
||||||
templateUrl: './api-logs.component.html',
|
|
||||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class ApiLogsComponent implements OnInit {
|
|
||||||
private service = inject(ApiManagementService);
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
|
|
||||||
vendors: ApiVendor[] = [];
|
|
||||||
filter = { vendorId: '', direction: '', result: '', search: '' };
|
|
||||||
items: ApiLog[] = [];
|
|
||||||
total = 0;
|
|
||||||
success = 0;
|
|
||||||
failed = 0;
|
|
||||||
offset = 0;
|
|
||||||
readonly limit = 15;
|
|
||||||
loading = false;
|
|
||||||
openLog: ApiLog | null = null;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const vendorId = this.route.snapshot.queryParamMap.get('vendorId');
|
|
||||||
if (vendorId) this.filter.vendorId = vendorId;
|
|
||||||
this.service.vendors().subscribe((v) => (this.vendors = v));
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.service.logs({ ...this.filter, offset: this.offset, limit: this.limit }).subscribe((r) => {
|
|
||||||
this.items = r.items;
|
|
||||||
this.total = r.total;
|
|
||||||
this.success = r.success;
|
|
||||||
this.failed = r.failed;
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
apply(): void {
|
|
||||||
this.offset = 0;
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.filter = { vendorId: '', direction: '', result: '', search: '' };
|
|
||||||
this.apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
page(delta: number): void {
|
|
||||||
this.offset = Math.max(0, this.offset + delta * this.limit);
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { NgModule } from '@angular/core';
|
|
||||||
import { Routes, RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { Route } from '../core/route/route.service';
|
|
||||||
import { ApiVendorsComponent } from './api-vendors/api-vendors.component';
|
|
||||||
import { ApiVendorSetupComponent } from './api-vendor-setup/api-vendor-setup.component';
|
|
||||||
import { ApiLogsComponent } from './api-logs/api-logs.component';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
|
||||||
Route.withShell([
|
|
||||||
{
|
|
||||||
path: 'api-management',
|
|
||||||
data: { title: 'API Management', breadcrumb: 'API Management' },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
redirectTo: 'vendors',
|
|
||||||
pathMatch: 'full'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'vendors',
|
|
||||||
data: { breadcrumb: 'Vendors' },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
component: ApiVendorsComponent,
|
|
||||||
data: { title: 'API Vendors & Setup' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ':id',
|
|
||||||
component: ApiVendorSetupComponent,
|
|
||||||
data: { title: 'Vendor Setup', breadcrumb: 'Setup' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'logs',
|
|
||||||
component: ApiLogsComponent,
|
|
||||||
data: { title: 'API Call Logs', breadcrumb: 'Call Logs' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
])
|
|
||||||
];
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [RouterModule.forChild(routes)],
|
|
||||||
exports: [RouterModule]
|
|
||||||
})
|
|
||||||
export class ApiManagementRoutingModule {}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { NgModule } from '@angular/core';
|
|
||||||
|
|
||||||
import { SharedModule } from '../shared/shared.module';
|
|
||||||
import { ApiManagementRoutingModule } from './api-management-routing.module';
|
|
||||||
import { ApiVendorsComponent } from './api-vendors/api-vendors.component';
|
|
||||||
import { ApiVendorSetupComponent } from './api-vendor-setup/api-vendor-setup.component';
|
|
||||||
import { ApiLogsComponent } from './api-logs/api-logs.component';
|
|
||||||
import { ApiLogDetailComponent } from './api-log-detail/api-log-detail.component';
|
|
||||||
|
|
||||||
/** API Management: connect any outside API service (CIC, SMS, WhatsApp ...) - vendor, work, API set, IN / OUT. */
|
|
||||||
@NgModule({
|
|
||||||
imports: [
|
|
||||||
SharedModule,
|
|
||||||
ApiManagementRoutingModule,
|
|
||||||
ApiVendorsComponent,
|
|
||||||
ApiVendorSetupComponent,
|
|
||||||
ApiLogsComponent,
|
|
||||||
ApiLogDetailComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ApiManagementModule {}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,777 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="cic-page">
|
|
||||||
<div class="cic-head">
|
|
||||||
<div>
|
|
||||||
<h1>{{ vendor?.name || 'Vendor setup' }}</h1>
|
|
||||||
<p>{{ vendor?.work }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="cic-actions">
|
|
||||||
@if (vendor) {
|
|
||||||
<span class="cic-badge" [class.good]="vendor.active">{{ vendor.active ? 'Active' : 'Switched off' }}</span>
|
|
||||||
<span
|
|
||||||
class="cic-badge"
|
|
||||||
[class.warn]="vendor.environment === 'SIMULATOR'"
|
|
||||||
[class.good]="vendor.environment === 'PRODUCTION'"
|
|
||||||
>{{ vendor.environment }}</span
|
|
||||||
>
|
|
||||||
}
|
|
||||||
<a class="cic-btn" routerLink="/api-management/vendors">← All vendors</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (error && !editing && !importOpen) {
|
|
||||||
<div class="cic-alert error">{{ error }}</div>
|
|
||||||
}
|
|
||||||
@if (info && !editing) {
|
|
||||||
<div class="cic-alert success">{{ info }}</div>
|
|
||||||
}
|
|
||||||
@if (loading && !vendor) {
|
|
||||||
<div class="cic-card cic-empty">Loading…</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (vendor) {
|
|
||||||
@if (vendor.configNote) {
|
|
||||||
<div class="cic-alert info">{{ vendor.configNote }}</div>
|
|
||||||
}
|
|
||||||
@if (vendor.links.length) {
|
|
||||||
<div class="cic-alert info">
|
|
||||||
{{ vendor.code }} also has its own screens:
|
|
||||||
@for (l of vendor.links; track l.route) {
|
|
||||||
<a class="cic-btn sm" style="margin-left: 6px" [routerLink]="l.route">{{ l.label }}</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cic-tabs">
|
|
||||||
<button [class.active]="tab === 'connection'" (click)="tab = 'connection'">1 · Connection</button>
|
|
||||||
<button [class.active]="tab === 'apis'" (click)="tab = 'apis'">2 · API set ({{ vendor.apis.length }})</button>
|
|
||||||
<button [class.active]="tab === 'test'" (click)="tab = 'test'">3 · Test</button>
|
|
||||||
<button [class.active]="tab === 'calls'" (click)="tab = 'calls'; loadCalls()">Recent calls</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CONNECTION -->
|
|
||||||
@if (tab === 'connection') {
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>Vendor and connection</h2>
|
|
||||||
<div class="cic-grid cols-2">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Vendor name</label>
|
|
||||||
<input [(ngModel)]="form.name" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Environment</label>
|
|
||||||
<select [(ngModel)]="form.environment">
|
|
||||||
<option value="SIMULATOR">SIMULATOR — no network call</option>
|
|
||||||
<option value="UAT">UAT — vendor's test environment</option>
|
|
||||||
<option value="PRODUCTION">PRODUCTION — live</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field" style="grid-column: 1 / -1">
|
|
||||||
<label>Work — what this vendor is used for</label>
|
|
||||||
<input [(ngModel)]="form.work" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field" style="grid-column: 1 / -1">
|
|
||||||
<label>Base URL</label>
|
|
||||||
<input class="cic-mono" [(ngModel)]="form.baseUrl" placeholder="https://api.vendor.example/v1" />
|
|
||||||
<span class="hint">Every OUT / pull API path is added to this. PRODUCTION needs https://.</span>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Authentication</label>
|
|
||||||
<select [(ngModel)]="form.authType">
|
|
||||||
@for (a of authTypes; track a.key) {
|
|
||||||
<option [value]="a.key">{{ a.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
@if (form.authType === 'BASIC') {
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>User id</label>
|
|
||||||
<input [(ngModel)]="form.username" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (form.authType === 'API_KEY') {
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>API key header name</label>
|
|
||||||
<input class="cic-mono" [(ngModel)]="form.apiKeyHeader" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (needsSecret()) {
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>{{ secretLabel() }}</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
[(ngModel)]="form.secret"
|
|
||||||
autocomplete="new-password"
|
|
||||||
[placeholder]="vendor.hasSecret ? '•••••• stored — leave blank to keep' : 'Enter to store (encrypted)'"
|
|
||||||
/>
|
|
||||||
@if (vendor.hasSecret) {
|
|
||||||
<label style="display: flex; gap: 6px; align-items: center; font-weight: 500; font-size: 12px">
|
|
||||||
<input type="checkbox" [(ngModel)]="form.clearSecret" style="width: auto" /> Remove the stored
|
|
||||||
credential
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Sender / member id</label>
|
|
||||||
<input [(ngModel)]="form.senderId" />
|
|
||||||
<span class="hint"
|
|
||||||
>The bank's id at this vendor (SMS sender id, WhatsApp number id, CIC member code …).</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Timeout (ms)</label>
|
|
||||||
<input type="number" min="1000" max="300000" [(ngModel)]="form.timeoutMs" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Retries on failure</label>
|
|
||||||
<input type="number" min="0" max="5" [(ngModel)]="form.retries" />
|
|
||||||
</div>
|
|
||||||
<label style="display: flex; gap: 8px; align-items: center; font-size: 13px">
|
|
||||||
<input type="checkbox" [(ngModel)]="form.active" /> Vendor active
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 style="margin: 18px 0 6px; font-size: 13px">
|
|
||||||
Default headers
|
|
||||||
<span class="muted" style="font-weight: 400"
|
|
||||||
>— sent on every API of this vendor (an API can override one)</span
|
|
||||||
>
|
|
||||||
</h4>
|
|
||||||
<mifosx-api-key-value-editor
|
|
||||||
[rows]="form.defaultHeaders"
|
|
||||||
keyLabel="Header"
|
|
||||||
keyPlaceholder="Accept"
|
|
||||||
addLabel="+ Add header"
|
|
||||||
emptyText="No default header."
|
|
||||||
></mifosx-api-key-value-editor>
|
|
||||||
|
|
||||||
<div class="cic-actions" style="margin-top: 16px">
|
|
||||||
<button class="cic-btn primary" (click)="saveConnection()" [disabled]="saving">
|
|
||||||
{{ saving ? 'Saving…' : 'Save connection' }}
|
|
||||||
</button>
|
|
||||||
<button class="cic-btn" (click)="tab = 'apis'">Next: API set →</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- API SET -->
|
|
||||||
@if (tab === 'apis') {
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>
|
|
||||||
API set
|
|
||||||
<span class="cic-actions">
|
|
||||||
<button class="cic-btn sm" (click)="newApi('OUT')">+ OUT API (CBS calls vendor)</button>
|
|
||||||
<button class="cic-btn sm" (click)="newApi('IN')">+ IN API (data comes into CBS)</button>
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
@if (!vendor.apis.length) {
|
|
||||||
<div class="cic-empty">
|
|
||||||
No API yet. Add one, then fill it in by hand or read it from a JSON / cURL / Postman file.
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>API</th>
|
|
||||||
<th>Direction</th>
|
|
||||||
<th>Call</th>
|
|
||||||
<th>Used for</th>
|
|
||||||
<th>Headers / query</th>
|
|
||||||
<th>Mapping</th>
|
|
||||||
<th>Active</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (a of vendor.apis; track a.id) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<b>{{ a.name }}</b>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class.brand]="a.direction === 'IN'" [class.ok]="a.direction === 'OUT'">{{
|
|
||||||
a.direction
|
|
||||||
}}</span>
|
|
||||||
@if (a.mode) {
|
|
||||||
<span class="muted" style="font-size: 11px"> {{ a.mode === 'PULL' ? 'pull' : 'push' }}</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px">{{ a.method }} {{ a.path }}</td>
|
|
||||||
<td style="max-width: 260px; white-space: normal">{{ a.usedFor }}</td>
|
|
||||||
<td>{{ a.headers.length }} / {{ a.queryParams.length }}</td>
|
|
||||||
<td>{{ a.requestRows.length }} / {{ a.responseRows.length }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class.good]="a.active">{{ a.active ? 'Yes' : 'Off' }}</span>
|
|
||||||
</td>
|
|
||||||
<td style="white-space: nowrap">
|
|
||||||
<button class="cic-btn sm" (click)="editApi(a)">Edit</button>
|
|
||||||
<button class="cic-btn sm danger" (click)="deleteApi(a)">✕</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- TEST -->
|
|
||||||
@if (tab === 'test') {
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>
|
|
||||||
Test an API
|
|
||||||
<span class="cic-card-sub">simulated — nothing is sent to the vendor; the result is added to the logs</span>
|
|
||||||
</h2>
|
|
||||||
@if (!vendor.apis.length) {
|
|
||||||
<div class="cic-empty">Add an API first.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-field" style="max-width: 420px">
|
|
||||||
<label>API</label>
|
|
||||||
<select [(ngModel)]="testApiId" (ngModelChange)="fillTestBody()">
|
|
||||||
@for (a of vendor.apis; track a.id) {
|
|
||||||
<option [ngValue]="a.id">{{ a.direction }} · {{ a.name }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
@if (testPreview; as p) {
|
|
||||||
<h4 style="margin: 14px 0 6px; font-size: 13px">The request</h4>
|
|
||||||
<pre class="cic-code" style="max-height: 200px">{{ p.method }} {{ p.url }}
|
|
||||||
@for (h of p.headers; track h.key) {
|
|
||||||
{{ h.key }}: {{ h.value }}
|
|
||||||
}</pre>
|
|
||||||
}
|
|
||||||
<div class="cic-field" style="margin-top: 12px">
|
|
||||||
<label>Request body</label>
|
|
||||||
<textarea class="cic-mono" rows="9" [(ngModel)]="testBody"></textarea>
|
|
||||||
</div>
|
|
||||||
<button class="cic-btn primary" style="margin-top: 12px" (click)="runTest()" [disabled]="testing">
|
|
||||||
{{ testing ? 'Sending…' : 'Send test' }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (testResult) {
|
|
||||||
<div class="cic-alert success">
|
|
||||||
{{ testResult.reference }} — HTTP {{ testResult.httpStatus }} in {{ testResult.durationMs }} ms
|
|
||||||
</div>
|
|
||||||
<div class="cic-split">
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>Response headers</h2>
|
|
||||||
<pre class="cic-code">{{ testResult.responseHeaders }}</pre>
|
|
||||||
</div>
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>Response body</h2>
|
|
||||||
<pre class="cic-code">{{ testResult.response || '(empty)' }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- RECENT CALLS -->
|
|
||||||
@if (tab === 'calls') {
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>
|
|
||||||
Recent calls
|
|
||||||
<a class="cic-btn sm" routerLink="/api-management/logs" [queryParams]="{ vendorId: vendor.id }">All logs</a>
|
|
||||||
</h2>
|
|
||||||
@if (!calls.length) {
|
|
||||||
<div class="cic-empty">No call yet.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>API</th>
|
|
||||||
<th>Direction</th>
|
|
||||||
<th>HTTP</th>
|
|
||||||
<th class="num">ms</th>
|
|
||||||
<th>Result</th>
|
|
||||||
<th>Reference</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (l of calls; track l.id) {
|
|
||||||
<tr class="clickable" (click)="openLog = l">
|
|
||||||
<td>{{ l.time | slice: 0 : 16 }}</td>
|
|
||||||
<td>{{ l.apiName }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class.brand]="l.direction === 'IN'" [class.ok]="l.direction === 'OUT'">{{
|
|
||||||
l.direction
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ l.httpStatus }}</td>
|
|
||||||
<td class="num">{{ l.durationMs }}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="cic-badge"
|
|
||||||
[class.good]="l.result === 'SUCCESS'"
|
|
||||||
[class.bad]="l.result === 'FAILED'"
|
|
||||||
>{{ l.result }}</span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px">{{ l.reference }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API editor -->
|
|
||||||
@if (editing) {
|
|
||||||
<div class="cic-modal-backdrop" (click)="editing = null">
|
|
||||||
<div class="cic-modal" style="width: min(1100px, 100%)" (click)="$event.stopPropagation()">
|
|
||||||
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: flex-start">
|
|
||||||
<h3 style="margin: 0">
|
|
||||||
{{ isNewApi ? 'New' : 'Edit' }} API
|
|
||||||
<span class="cic-badge" [class.brand]="editing.direction === 'IN'" [class.ok]="editing.direction === 'OUT'">{{
|
|
||||||
editing.direction
|
|
||||||
}}</span>
|
|
||||||
@if (editing.mode) {
|
|
||||||
<span class="muted" style="font-size: 12px; font-weight: 500">
|
|
||||||
{{ editing.mode === 'PULL' ? 'pull' : 'push' }}</span
|
|
||||||
>
|
|
||||||
}
|
|
||||||
</h3>
|
|
||||||
<button class="cic-btn sm primary" (click)="openImport()">⬆ Import from JSON / cURL / Postman</button>
|
|
||||||
</div>
|
|
||||||
<p class="muted" style="font-size: 12px; margin: 6px 0 10px">
|
|
||||||
@if (editing.direction === 'OUT') {
|
|
||||||
OUT — the CBS calls the vendor: fill the request it sends and the response it expects.
|
|
||||||
} @else if (editing.mode === 'PULL') {
|
|
||||||
IN (pull) — the CBS asks the vendor for data on a schedule: fill the request it sends and the response it
|
|
||||||
reads.
|
|
||||||
} @else {
|
|
||||||
IN (push) — the vendor calls a CBS path: fill what the vendor sends us and what we answer.
|
|
||||||
}
|
|
||||||
Type everything in, or read it from a file with the import button — then check each tab.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if (info) {
|
|
||||||
<div class="cic-alert success">{{ info }}</div>
|
|
||||||
}
|
|
||||||
@if (error) {
|
|
||||||
<div class="cic-alert error">{{ error }}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cic-tabs">
|
|
||||||
<button [class.active]="editorTab === 'basics'" (click)="editorTab = 'basics'">1 · Basics</button>
|
|
||||||
<button [class.active]="editorTab === 'request'" (click)="editorTab = 'request'">
|
|
||||||
2 · {{ outbound ? 'Request' : 'What the vendor sends' }}
|
|
||||||
</button>
|
|
||||||
<button [class.active]="editorTab === 'response'" (click)="editorTab = 'response'">
|
|
||||||
3 · {{ outbound ? 'Response' : 'Our answer' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- BASICS -->
|
|
||||||
@if (editorTab === 'basics') {
|
|
||||||
<div class="cic-grid cols-4">
|
|
||||||
<div class="cic-field" style="grid-column: span 2">
|
|
||||||
<label>API name</label>
|
|
||||||
<input [(ngModel)]="editing.name" placeholder="e.g. Send SMS" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Direction</label>
|
|
||||||
<select [(ngModel)]="editing.direction" (ngModelChange)="directionChanged()">
|
|
||||||
<option value="OUT">OUT — CBS calls vendor</option>
|
|
||||||
<option value="IN">IN — data comes into CBS</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
@if (editing.direction === 'IN') {
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>How data arrives</label>
|
|
||||||
<select [(ngModel)]="editing.mode">
|
|
||||||
<option value="PULL">PULL — CBS asks on a schedule</option>
|
|
||||||
<option value="PUSH">PUSH — vendor calls a CBS path</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div></div>
|
|
||||||
}
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>HTTP method</label>
|
|
||||||
<select [(ngModel)]="editing.method">
|
|
||||||
@for (m of methods; track m) {
|
|
||||||
<option [value]="m">{{ m }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field" style="grid-column: span 3">
|
|
||||||
<label>{{ outbound ? "Vendor's API path (after the base URL)" : 'CBS path the vendor calls' }}</label>
|
|
||||||
<input
|
|
||||||
class="cic-mono"
|
|
||||||
[(ngModel)]="editing.path"
|
|
||||||
(ngModelChange)="pathChanged()"
|
|
||||||
placeholder="/messages/send"
|
|
||||||
/>
|
|
||||||
<span class="hint"
|
|
||||||
>Use {{ '{' }}name{{ '}' }} for a value that changes, e.g. /v21.0/{{ '{' }}senderId{{
|
|
||||||
'}'
|
|
||||||
}}/messages.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field" style="grid-column: span 3">
|
|
||||||
<label>When is it used</label>
|
|
||||||
<input [(ngModel)]="editing.usedFor" placeholder="e.g. OTP and transaction alerts" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Timeout for this API (ms)</label>
|
|
||||||
<input type="number" min="1000" max="300000" [(ngModel)]="editing.timeoutMs" placeholder="vendor's" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Retries for this API</label>
|
|
||||||
<input type="number" min="0" max="5" [(ngModel)]="editing.retries" placeholder="vendor's" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label style="display: flex; gap: 8px; align-items: center; font-size: 13px; margin: 12px 0">
|
|
||||||
<input type="checkbox" [(ngModel)]="editing.active" /> API active
|
|
||||||
</label>
|
|
||||||
|
|
||||||
@if (preview(editing); as p) {
|
|
||||||
<h4 style="margin: 14px 0 6px; font-size: 13px">
|
|
||||||
{{ outbound ? 'The call as it will go out' : 'The call the vendor will make to us' }}
|
|
||||||
<span class="muted" style="font-weight: 400"> (credentials masked)</span>
|
|
||||||
</h4>
|
|
||||||
<pre class="cic-code" style="max-height: 220px">{{ p.method }} {{ p.url }}
|
|
||||||
@for (h of p.headers; track h.key) {
|
|
||||||
{{ h.key }}: {{ h.value }}
|
|
||||||
}
|
|
||||||
{{ p.body }}</pre>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- REQUEST -->
|
|
||||||
@if (editorTab === 'request') {
|
|
||||||
<h4 style="margin: 4px 0 6px; font-size: 13px">Path parameters</h4>
|
|
||||||
<mifosx-api-key-value-editor
|
|
||||||
[rows]="editing.pathParams"
|
|
||||||
keyLabel="Parameter"
|
|
||||||
valueLabel="Value (a CBS placeholder or fixed value)"
|
|
||||||
[fixedKeys]="true"
|
|
||||||
emptyText="The path has no {name} placeholder."
|
|
||||||
></mifosx-api-key-value-editor>
|
|
||||||
|
|
||||||
<h4 style="margin: 16px 0 6px; font-size: 13px">Query parameters</h4>
|
|
||||||
<mifosx-api-key-value-editor
|
|
||||||
[rows]="editing.queryParams"
|
|
||||||
keyLabel="Parameter"
|
|
||||||
keyPlaceholder="version"
|
|
||||||
addLabel="+ Add query parameter"
|
|
||||||
emptyText="No query parameter."
|
|
||||||
></mifosx-api-key-value-editor>
|
|
||||||
|
|
||||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
|
||||||
Headers
|
|
||||||
<span class="muted" style="font-weight: 400"
|
|
||||||
>— this API's own; the vendor's default headers and its authentication are added automatically</span
|
|
||||||
>
|
|
||||||
</h4>
|
|
||||||
<mifosx-api-key-value-editor
|
|
||||||
[rows]="editing.headers"
|
|
||||||
keyLabel="Header"
|
|
||||||
keyPlaceholder="X-Request-Id"
|
|
||||||
valuePlaceholder="{{ '{{' }}request.reference{{ '}}' }}"
|
|
||||||
addLabel="+ Add header"
|
|
||||||
emptyText="No header of its own."
|
|
||||||
></mifosx-api-key-value-editor>
|
|
||||||
|
|
||||||
<div class="cic-grid cols-4" style="margin-top: 16px">
|
|
||||||
<div class="cic-field" style="grid-column: span 2">
|
|
||||||
<label>Content type</label>
|
|
||||||
<select [(ngModel)]="editing.contentType">
|
|
||||||
@for (c of contentTypes; track c.key) {
|
|
||||||
<option [value]="c.key">{{ c.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cic-field" style="margin-top: 12px">
|
|
||||||
<label>{{ outbound ? 'Request payload' : 'Payload the vendor sends us' }}</label>
|
|
||||||
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.requestBody"></textarea>
|
|
||||||
<span class="hint">
|
|
||||||
{{
|
|
||||||
outbound
|
|
||||||
? 'The constant parts of the payload; the mapping below puts CBS values into it.'
|
|
||||||
: 'A sample of what arrives; the mapping reads values out of it.'
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
|
||||||
{{ mappingTitle('request') }}
|
|
||||||
<span class="muted" style="font-weight: 400; font-size: 12px">
|
|
||||||
· {{ matchedCount('request') }} of {{ editing.requestRows.length }} rows found in the sample
|
|
||||||
{{ 'request' === 'request' ? 'payload' : 'body' }}
|
|
||||||
</span>
|
|
||||||
<button class="cic-btn sm" style="margin-left: 8px" (click)="generateRows('request')">
|
|
||||||
Build rows from the payload
|
|
||||||
</button>
|
|
||||||
</h4>
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{ fromLabel('request') }}</th>
|
|
||||||
<th>{{ toLabel('request') }}</th>
|
|
||||||
<th>Transform</th>
|
|
||||||
<th>Fixed / default value</th>
|
|
||||||
<th>In the sample payload</th>
|
|
||||||
<th>Required</th>
|
|
||||||
<th style="width: 40px"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@let requestMatch = sampleMatches('request');
|
|
||||||
@for (r of editing.requestRows; track $index; let i = $index) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
@if (fromIsCbs('request')) {
|
|
||||||
<select [(ngModel)]="r.from">
|
|
||||||
<option value="">— fixed value (set it on the right) —</option>
|
|
||||||
@for (f of cbsFields; track f.key) {
|
|
||||||
<option [value]="f.key">{{ f.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
} @else {
|
|
||||||
<input class="cic-mono" [(ngModel)]="r.from" />
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td><input class="cic-mono" [(ngModel)]="r.to" /></td>
|
|
||||||
<td>
|
|
||||||
<select [(ngModel)]="r.transform">
|
|
||||||
@for (t of transforms; track t.key) {
|
|
||||||
<option [value]="t.key">{{ t.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td><input [(ngModel)]="r.defaultValue" /></td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px; white-space: nowrap">
|
|
||||||
@if (requestMatch[i].found) {
|
|
||||||
<span class="tone-good">✓ {{ requestMatch[i].value }}</span>
|
|
||||||
} @else if (r.defaultValue) {
|
|
||||||
<span class="muted">fixed: {{ r.defaultValue }}</span>
|
|
||||||
} @else {
|
|
||||||
<span class="tone-bad">✗ {{ requestMatch[i].value || 'no path' }}</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="r.mandatory" style="width: auto" />
|
|
||||||
</td>
|
|
||||||
<td><button class="cic-btn sm danger" (click)="removeRow('request', i)">✕</button></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<button class="cic-btn sm" style="margin-top: 8px" (click)="addRow('request')">+ Add field</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- RESPONSE -->
|
|
||||||
@if (editorTab === 'response') {
|
|
||||||
<div class="cic-grid cols-4">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Success status code(s)</label>
|
|
||||||
<input class="cic-mono" [(ngModel)]="editing.successCodes" placeholder="200, 202" />
|
|
||||||
<span class="hint">{{
|
|
||||||
outbound ? 'Codes the vendor answers with when it worked.' : 'Codes the CBS answers with.'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
|
||||||
{{ outbound ? 'Response headers' : 'Headers of our answer' }}
|
|
||||||
</h4>
|
|
||||||
<mifosx-api-key-value-editor
|
|
||||||
[rows]="editing.responseHeaders"
|
|
||||||
keyLabel="Header"
|
|
||||||
keyPlaceholder="Content-Type"
|
|
||||||
addLabel="+ Add header"
|
|
||||||
emptyText="No response header noted."
|
|
||||||
></mifosx-api-key-value-editor>
|
|
||||||
|
|
||||||
<div class="cic-grid cols-2" style="margin-top: 16px">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>{{ outbound ? 'Success response body' : 'Our answer body' }}</label>
|
|
||||||
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.responseBody"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Error response body</label>
|
|
||||||
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.errorBody"></textarea>
|
|
||||||
<span class="hint"
|
|
||||||
>What comes back when it fails (wrong key, rate limit …) — shown in the logs for a failed call.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
|
||||||
{{ mappingTitle('response') }}
|
|
||||||
<span class="muted" style="font-weight: 400; font-size: 12px">
|
|
||||||
· {{ matchedCount('response') }} of {{ editing.responseRows.length }} rows found in the sample
|
|
||||||
{{ 'response' === 'request' ? 'payload' : 'body' }}
|
|
||||||
</span>
|
|
||||||
<button class="cic-btn sm" style="margin-left: 8px" (click)="generateRows('response')">
|
|
||||||
Build rows from the body
|
|
||||||
</button>
|
|
||||||
</h4>
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{ fromLabel('response') }}</th>
|
|
||||||
<th>{{ toLabel('response') }}</th>
|
|
||||||
<th>Transform</th>
|
|
||||||
<th>Fixed / default value</th>
|
|
||||||
<th>In the sample payload</th>
|
|
||||||
<th>Required</th>
|
|
||||||
<th style="width: 40px"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@let responseMatch = sampleMatches('response');
|
|
||||||
@for (r of editing.responseRows; track $index; let i = $index) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
@if (fromIsCbs('response')) {
|
|
||||||
<select [(ngModel)]="r.from">
|
|
||||||
<option value="">— fixed value (set it on the right) —</option>
|
|
||||||
@for (f of cbsFields; track f.key) {
|
|
||||||
<option [value]="f.key">{{ f.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
} @else {
|
|
||||||
<input class="cic-mono" [(ngModel)]="r.from" />
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td><input class="cic-mono" [(ngModel)]="r.to" /></td>
|
|
||||||
<td>
|
|
||||||
<select [(ngModel)]="r.transform">
|
|
||||||
@for (t of transforms; track t.key) {
|
|
||||||
<option [value]="t.key">{{ t.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td><input [(ngModel)]="r.defaultValue" /></td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px; white-space: nowrap">
|
|
||||||
@if (responseMatch[i].found) {
|
|
||||||
<span class="tone-good">✓ {{ responseMatch[i].value }}</span>
|
|
||||||
} @else if (r.defaultValue) {
|
|
||||||
<span class="muted">fixed: {{ r.defaultValue }}</span>
|
|
||||||
} @else {
|
|
||||||
<span class="tone-bad">✗ {{ responseMatch[i].value || 'no path' }}</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="r.mandatory" style="width: auto" />
|
|
||||||
</td>
|
|
||||||
<td><button class="cic-btn sm danger" (click)="removeRow('response', i)">✕</button></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<button class="cic-btn sm" style="margin-top: 8px" (click)="addRow('response')">+ Add field</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="foot">
|
|
||||||
<button class="cic-btn" (click)="editing = null">Cancel</button>
|
|
||||||
<button class="cic-btn primary" (click)="saveApi()" [disabled]="savingApi">
|
|
||||||
{{ savingApi ? 'Saving…' : 'Save API' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Import from JSON / cURL / Postman / HAR -->
|
|
||||||
@if (importOpen && editing) {
|
|
||||||
<div class="cic-modal-backdrop" style="z-index: 1100" (click)="importOpen = false">
|
|
||||||
<div class="cic-modal" style="width: min(1000px, 100%)" (click)="$event.stopPropagation()">
|
|
||||||
<h3>Import from JSON / cURL / Postman</h3>
|
|
||||||
<p class="muted" style="font-size: 12px; margin: 0 0 10px">
|
|
||||||
Paste it or choose a file —
|
|
||||||
{{ outbound ? 'the request and the response' : 'what the vendor sends us and what we answer' }}. Any of these
|
|
||||||
works: a <b>cURL</b> command, a <b>Postman</b> collection or request, a <b>HAR</b> capture, an API definition
|
|
||||||
(<code>{{ '{' }} method, url, headers, query, body, status … {{ '}' }}</code
|
|
||||||
>), or just the <b>JSON payload</b>. The method, URL / path, query parameters, headers, content type, payload,
|
|
||||||
status code, response headers, bodies and a first CBS mapping are filled in for you. Passwords, keys and tokens
|
|
||||||
are never kept.
|
|
||||||
</p>
|
|
||||||
@if (importError) {
|
|
||||||
<div class="cic-alert error">{{ importError }}</div>
|
|
||||||
}
|
|
||||||
<div class="cic-grid cols-2">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label
|
|
||||||
>{{ outbound ? 'Request' : 'What the vendor sends us' }} — cURL, Postman, HAR, definition or payload</label
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
class="cic-mono"
|
|
||||||
rows="12"
|
|
||||||
[(ngModel)]="importRequestText"
|
|
||||||
placeholder="curl -X POST 'https://api.vendor.in/v1/send' -H 'Content-Type: application/json' -d '{ ... }'"
|
|
||||||
></textarea>
|
|
||||||
<div style="display: flex; gap: 8px; align-items: center">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".json,.txt,.curl,.har,.sh,application/json,text/plain"
|
|
||||||
(change)="onFile($event, 'request')"
|
|
||||||
/>
|
|
||||||
@if (importRequestFile) {
|
|
||||||
<span class="muted" style="font-size: 12px">{{ importRequestFile }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>{{ outbound ? 'Response' : 'Our answer' }} — JSON body, or with status and headers (optional)</label>
|
|
||||||
<textarea
|
|
||||||
class="cic-mono"
|
|
||||||
rows="12"
|
|
||||||
[(ngModel)]="importResponseText"
|
|
||||||
placeholder='{ "status": 200, "headers": { "Content-Type": "application/json" }, "body": { ... } }'
|
|
||||||
></textarea>
|
|
||||||
<div style="display: flex; gap: 8px; align-items: center">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".json,.txt,.har,application/json,text/plain"
|
|
||||||
(change)="onFile($event, 'response')"
|
|
||||||
/>
|
|
||||||
@if (importResponseFile) {
|
|
||||||
<span class="muted" style="font-size: 12px">{{ importResponseFile }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: 20px; flex-wrap: wrap; margin-top: 12px; font-size: 13px">
|
|
||||||
<label style="display: flex; gap: 6px; align-items: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="importReplace" /> Replace what is there now (headers, query, mapping rows)
|
|
||||||
— untick to add to it
|
|
||||||
</label>
|
|
||||||
<label style="display: flex; gap: 6px; align-items: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="importGenerate" /> Build the CBS field mapping from the payloads
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="foot">
|
|
||||||
<button class="cic-btn" (click)="importOpen = false">Cancel</button>
|
|
||||||
<button class="cic-btn primary" (click)="applyImport()">Read and fill in</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<mifosx-api-log-detail [log]="openLog" (closed)="openLog = null"></mifosx-api-log-detail>
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { CommonModule } from '@angular/common';
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { ApiKeyValueEditorComponent } from '../api-key-value-editor/api-key-value-editor.component';
|
|
||||||
import { ApiLogDetailComponent } from '../api-log-detail/api-log-detail.component';
|
|
||||||
import {
|
|
||||||
guessRequestRows,
|
|
||||||
guessResponseRows,
|
|
||||||
ImportedRequest,
|
|
||||||
ImportedResponse,
|
|
||||||
parseRequestText,
|
|
||||||
parseResponseText
|
|
||||||
} from '../api-import.util';
|
|
||||||
import {
|
|
||||||
API_AUTH_TYPES,
|
|
||||||
API_CBS_FIELDS,
|
|
||||||
API_CONTENT_TYPES,
|
|
||||||
API_METHODS,
|
|
||||||
API_TRANSFORMS,
|
|
||||||
ApiLog,
|
|
||||||
ApiManagementService,
|
|
||||||
ApiMappingRow,
|
|
||||||
ApiVendor,
|
|
||||||
blankApi,
|
|
||||||
buildRequestPreview,
|
|
||||||
callsVendor,
|
|
||||||
KeyValue,
|
|
||||||
pathParamNames,
|
|
||||||
RequestPreview,
|
|
||||||
VendorApi
|
|
||||||
} from '../api-management.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One vendor's setup: its connection (URL, authentication, default headers, credentials), its API set - each API IN or
|
|
||||||
* OUT with the full HTTP detail and the field mapping, typed in or read from a JSON / cURL / Postman / HAR file - a
|
|
||||||
* test call, and its recent calls.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-api-vendor-setup',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
RouterModule,
|
|
||||||
ApiLogDetailComponent,
|
|
||||||
ApiKeyValueEditorComponent
|
|
||||||
],
|
|
||||||
templateUrl: './api-vendor-setup.component.html',
|
|
||||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class ApiVendorSetupComponent implements OnInit {
|
|
||||||
private service = inject(ApiManagementService);
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
|
|
||||||
vendor: ApiVendor | null = null;
|
|
||||||
tab: 'connection' | 'apis' | 'test' | 'calls' = 'connection';
|
|
||||||
loading = false;
|
|
||||||
error: string | null = null;
|
|
||||||
info: string | null = null;
|
|
||||||
|
|
||||||
// connection
|
|
||||||
form: any = {};
|
|
||||||
saving = false;
|
|
||||||
|
|
||||||
// API set
|
|
||||||
editing: VendorApi | null = null;
|
|
||||||
isNewApi = false;
|
|
||||||
savingApi = false;
|
|
||||||
editorTab: 'basics' | 'request' | 'response' = 'basics';
|
|
||||||
|
|
||||||
// import from JSON / cURL / Postman / HAR
|
|
||||||
importOpen = false;
|
|
||||||
importRequestText = '';
|
|
||||||
importResponseText = '';
|
|
||||||
importRequestFile = '';
|
|
||||||
importResponseFile = '';
|
|
||||||
importReplace = true;
|
|
||||||
importGenerate = true;
|
|
||||||
importError: string | null = null;
|
|
||||||
|
|
||||||
// test
|
|
||||||
testApiId: number | null = null;
|
|
||||||
testBody = '';
|
|
||||||
testing = false;
|
|
||||||
testResult: ApiLog | null = null;
|
|
||||||
|
|
||||||
// calls
|
|
||||||
calls: ApiLog[] = [];
|
|
||||||
openLog: ApiLog | null = null;
|
|
||||||
|
|
||||||
readonly authTypes = Object.entries(API_AUTH_TYPES).map(
|
|
||||||
([
|
|
||||||
key,
|
|
||||||
label
|
|
||||||
]) => ({ key, label })
|
|
||||||
);
|
|
||||||
readonly contentTypes = Object.entries(API_CONTENT_TYPES).map(
|
|
||||||
([
|
|
||||||
key,
|
|
||||||
label
|
|
||||||
]) => ({ key, label })
|
|
||||||
);
|
|
||||||
readonly transforms = Object.entries(API_TRANSFORMS).map(
|
|
||||||
([
|
|
||||||
key,
|
|
||||||
label
|
|
||||||
]) => ({ key, label })
|
|
||||||
);
|
|
||||||
readonly cbsFields = Object.entries(API_CBS_FIELDS).map(
|
|
||||||
([
|
|
||||||
key,
|
|
||||||
label
|
|
||||||
]) => ({ key, label })
|
|
||||||
);
|
|
||||||
readonly methods = API_METHODS;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.load(Number(this.route.snapshot.paramMap.get('id')));
|
|
||||||
}
|
|
||||||
|
|
||||||
load(id: number): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.service.vendor(id).subscribe({
|
|
||||||
next: (v) => {
|
|
||||||
this.vendor = v;
|
|
||||||
this.loading = false;
|
|
||||||
this.resetForm(v);
|
|
||||||
if (!this.testApiId || !v.apis.some((a) => a.id === this.testApiId)) this.testApiId = v.apis[0]?.id ?? null;
|
|
||||||
this.fillTestBody();
|
|
||||||
this.loadCalls();
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.loading = false;
|
|
||||||
this.error = e?.error?.defaultUserMessage ?? 'Could not load the vendor.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetForm(v: ApiVendor): void {
|
|
||||||
this.form = {
|
|
||||||
name: v.name,
|
|
||||||
work: v.work,
|
|
||||||
environment: v.environment,
|
|
||||||
baseUrl: v.baseUrl,
|
|
||||||
authType: v.authType,
|
|
||||||
username: v.username,
|
|
||||||
apiKeyHeader: v.apiKeyHeader,
|
|
||||||
secret: '',
|
|
||||||
clearSecret: false,
|
|
||||||
senderId: v.senderId,
|
|
||||||
timeoutMs: v.timeoutMs,
|
|
||||||
retries: v.retries,
|
|
||||||
defaultHeaders: JSON.parse(JSON.stringify(v.defaultHeaders)),
|
|
||||||
active: v.active
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
loadCalls(): void {
|
|
||||||
if (!this.vendor) return;
|
|
||||||
this.service.logs({ vendorId: String(this.vendor.id), limit: 12 }).subscribe((r) => (this.calls = r.items));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── connection ──
|
|
||||||
|
|
||||||
needsSecret(): boolean {
|
|
||||||
return this.form.authType !== 'NONE';
|
|
||||||
}
|
|
||||||
|
|
||||||
secretLabel(): string {
|
|
||||||
const a = this.form.authType;
|
|
||||||
return a === 'BASIC'
|
|
||||||
? 'Password'
|
|
||||||
: a === 'API_KEY'
|
|
||||||
? 'API key'
|
|
||||||
: a === 'BEARER_TOKEN'
|
|
||||||
? 'Bearer token'
|
|
||||||
: 'Client secret';
|
|
||||||
}
|
|
||||||
|
|
||||||
saveConnection(): void {
|
|
||||||
if (!this.vendor) return;
|
|
||||||
this.saving = true;
|
|
||||||
this.error = null;
|
|
||||||
this.info = null;
|
|
||||||
const body = { ...this.form };
|
|
||||||
if (!body.secret) delete body.secret;
|
|
||||||
this.service.saveVendor(this.vendor.id, body).subscribe({
|
|
||||||
next: (v) => {
|
|
||||||
this.saving = false;
|
|
||||||
this.vendor = v;
|
|
||||||
this.resetForm(v);
|
|
||||||
this.info = `${v.name} connection saved.`;
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.saving = false;
|
|
||||||
this.error = e?.error?.defaultUserMessage ?? 'Could not save the connection.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── API set ──
|
|
||||||
|
|
||||||
newApi(direction: 'IN' | 'OUT'): void {
|
|
||||||
this.isNewApi = true;
|
|
||||||
this.error = null;
|
|
||||||
this.info = null;
|
|
||||||
this.editorTab = 'basics';
|
|
||||||
this.editing = blankApi(direction);
|
|
||||||
}
|
|
||||||
|
|
||||||
editApi(a: VendorApi): void {
|
|
||||||
this.isNewApi = false;
|
|
||||||
this.error = null;
|
|
||||||
this.info = null;
|
|
||||||
this.editorTab = 'basics';
|
|
||||||
this.editing = JSON.parse(JSON.stringify(a));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the CBS makes the HTTP call (OUT, or IN pull); false for an IN push webhook. */
|
|
||||||
get outbound(): boolean {
|
|
||||||
return !!this.editing && callsVendor(this.editing);
|
|
||||||
}
|
|
||||||
|
|
||||||
directionChanged(): void {
|
|
||||||
if (!this.editing) return;
|
|
||||||
this.editing.mode = this.editing.direction === 'IN' ? (this.editing.mode ?? 'PUSH') : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keeps the path-parameter table in step with the {names} typed in the path. */
|
|
||||||
pathChanged(): void {
|
|
||||||
if (!this.editing) return;
|
|
||||||
const names = pathParamNames(this.editing.path);
|
|
||||||
const kept = this.editing.pathParams.filter((p) => names.includes(p.key));
|
|
||||||
for (const n of names) {
|
|
||||||
if (!kept.some((p) => p.key === n))
|
|
||||||
kept.push({ key: n, value: '', description: '', enabled: true, secret: false });
|
|
||||||
}
|
|
||||||
this.editing.pathParams = kept;
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsOf(side: 'request' | 'response'): ApiMappingRow[] {
|
|
||||||
if (!this.editing) return [];
|
|
||||||
return side === 'request' ? this.editing.requestRows : this.editing.responseRows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the left column of a mapping table holds a CBS value (a pick list) or a path in the vendor's JSON. */
|
|
||||||
fromIsCbs(side: 'request' | 'response'): boolean {
|
|
||||||
return this.outbound ? side === 'request' : side === 'response';
|
|
||||||
}
|
|
||||||
|
|
||||||
mappingTitle(side: 'request' | 'response'): string {
|
|
||||||
if (this.outbound) return side === 'request' ? 'Request mapping (CBS → vendor)' : 'Response mapping (vendor → CBS)';
|
|
||||||
return side === 'request' ? 'Incoming data mapping (vendor → CBS)' : 'Answer mapping (CBS → vendor)';
|
|
||||||
}
|
|
||||||
|
|
||||||
fromLabel(side: 'request' | 'response'): string {
|
|
||||||
return this.fromIsCbs(side) ? 'CBS value' : "Vendor's JSON path";
|
|
||||||
}
|
|
||||||
|
|
||||||
toLabel(side: 'request' | 'response'): string {
|
|
||||||
if (this.fromIsCbs(side)) return side === 'request' ? "Vendor's request path" : 'Answer path';
|
|
||||||
return 'CBS field';
|
|
||||||
}
|
|
||||||
|
|
||||||
private parsedCache = new Map<string, unknown>();
|
|
||||||
|
|
||||||
private parsed(text: string): unknown {
|
|
||||||
if (!this.parsedCache.has(text)) {
|
|
||||||
let v: unknown = undefined;
|
|
||||||
try {
|
|
||||||
v = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
v = undefined;
|
|
||||||
}
|
|
||||||
if (this.parsedCache.size > 20) this.parsedCache.clear();
|
|
||||||
this.parsedCache.set(text, v);
|
|
||||||
}
|
|
||||||
return this.parsedCache.get(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads a.b[0].c out of a parsed payload; undefined when it is not there. */
|
|
||||||
private readPath(root: unknown, path: string): unknown {
|
|
||||||
let cur: any = root;
|
|
||||||
for (const part of path.match(/[^.[\]]+/g) ?? []) {
|
|
||||||
if (cur === null || cur === undefined) return undefined;
|
|
||||||
cur = cur[/^\d+$/.test(part) ? Number(part) : part];
|
|
||||||
}
|
|
||||||
return cur;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How a mapping row matches the sample payload on its side: the value found at the row's JSON path. Rows after a
|
|
||||||
* list (e.g. data.alerts) are read from the first item of that list, as the CBS does for each item.
|
|
||||||
*/
|
|
||||||
sampleMatches(side: 'request' | 'response'): { found: boolean; value: string }[] {
|
|
||||||
if (!this.editing) return [];
|
|
||||||
const body = this.parsed(side === 'request' ? this.editing.requestBody : this.editing.responseBody);
|
|
||||||
const pathIsTo = this.fromIsCbs(side);
|
|
||||||
let item: unknown = undefined;
|
|
||||||
return this.rowsOf(side).map((r) => {
|
|
||||||
const path = (pathIsTo ? r.to : r.from).trim();
|
|
||||||
if (!path || body === undefined) return { found: false, value: body === undefined ? 'payload is not JSON' : '' };
|
|
||||||
let v = this.readPath(body, path);
|
|
||||||
if (v === undefined && item !== undefined) v = this.readPath(item, path);
|
|
||||||
if (Array.isArray(v)) {
|
|
||||||
if (item === undefined && v.length) item = v[0];
|
|
||||||
return { found: true, value: `list of ${v.length}` };
|
|
||||||
}
|
|
||||||
if (v === undefined) return { found: false, value: 'not in the sample payload' };
|
|
||||||
const text = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
||||||
return { found: true, value: text.length > 40 ? text.slice(0, 40) + '…' : text };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
matchedCount(side: 'request' | 'response'): number {
|
|
||||||
return this.sampleMatches(side).filter((m) => m.found).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
addRow(side: 'request' | 'response'): void {
|
|
||||||
this.rowsOf(side).push({ from: '', to: '', transform: 'NONE', defaultValue: '', mandatory: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
removeRow(side: 'request' | 'response', i: number): void {
|
|
||||||
this.rowsOf(side).splice(i, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds mapping rows from the payload typed or imported for that side. */
|
|
||||||
generateRows(side: 'request' | 'response'): void {
|
|
||||||
if (!this.editing || !this.vendor) return;
|
|
||||||
const body = side === 'request' ? this.editing.requestBody : this.editing.responseBody;
|
|
||||||
// Reading values OUT of a payload (vendor → CBS) vs putting values INTO one (CBS → vendor).
|
|
||||||
const reading = !this.fromIsCbs(side);
|
|
||||||
const rows = reading ? guessResponseRows(body, this.vendor.code) : guessRequestRows(body);
|
|
||||||
if (!rows.length) {
|
|
||||||
this.error = 'That payload has no JSON values to build a mapping from.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.error = null;
|
|
||||||
const target = this.rowsOf(side);
|
|
||||||
const have = new Set(target.map((r) => r.to));
|
|
||||||
target.push(...rows.filter((r) => !have.has(r.to)));
|
|
||||||
this.info = `${rows.length} mapping row(s) read from the payload. Review the CBS fields and adjust.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveApi(): void {
|
|
||||||
if (!this.vendor || !this.editing) return;
|
|
||||||
this.savingApi = true;
|
|
||||||
this.error = null;
|
|
||||||
this.service.saveApi(this.vendor.id, this.editing).subscribe({
|
|
||||||
next: (a) => {
|
|
||||||
this.savingApi = false;
|
|
||||||
this.info = `${a.name} saved.`;
|
|
||||||
this.editing = null;
|
|
||||||
this.load(this.vendor!.id);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.savingApi = false;
|
|
||||||
this.error = e?.error?.defaultUserMessage ?? 'Could not save the API.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteApi(a: VendorApi): void {
|
|
||||||
if (!this.vendor || !confirm(`Delete the API "${a.name}"?`)) return;
|
|
||||||
this.service.deleteApi(this.vendor.id, a.id).subscribe(() => {
|
|
||||||
this.editing = null;
|
|
||||||
this.info = `${a.name} deleted.`;
|
|
||||||
this.load(this.vendor!.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The request this API would send (or receive), as the Test tab and the editor show it. */
|
|
||||||
preview(a: VendorApi | null): RequestPreview | null {
|
|
||||||
return this.vendor && a ? buildRequestPreview(this.vendor, a) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── import ──
|
|
||||||
|
|
||||||
openImport(): void {
|
|
||||||
this.importOpen = true;
|
|
||||||
this.importError = null;
|
|
||||||
this.importReplace = this.isNewApi;
|
|
||||||
this.importRequestText = '';
|
|
||||||
this.importResponseText = '';
|
|
||||||
this.importRequestFile = '';
|
|
||||||
this.importResponseFile = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
onFile(event: Event, which: 'request' | 'response'): void {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
this.importError = 'The file is larger than 5 MB.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const text = String(reader.result ?? '');
|
|
||||||
if (which === 'request') {
|
|
||||||
this.importRequestText = text;
|
|
||||||
this.importRequestFile = file.name;
|
|
||||||
} else {
|
|
||||||
this.importResponseText = text;
|
|
||||||
this.importResponseFile = file.name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
applyImport(): void {
|
|
||||||
if (!this.editing || !this.vendor) return;
|
|
||||||
this.importError = null;
|
|
||||||
let req: ImportedRequest | null = null;
|
|
||||||
let res: ImportedResponse | null = null;
|
|
||||||
try {
|
|
||||||
if (this.importRequestText.trim()) req = parseRequestText(this.importRequestText);
|
|
||||||
if (this.importResponseText.trim()) res = parseResponseText(this.importResponseText);
|
|
||||||
else if (req?.response) res = req.response;
|
|
||||||
} catch (e: any) {
|
|
||||||
this.importError = e?.message ?? 'Could not read the file.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!req && !res) {
|
|
||||||
this.importError = 'Paste or choose a request, a response, or both.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const a = this.editing;
|
|
||||||
const done: string[] = [];
|
|
||||||
const notes: string[] = [
|
|
||||||
...(req?.notes ?? []),
|
|
||||||
...(res?.notes ?? [])
|
|
||||||
];
|
|
||||||
const replace = this.importReplace;
|
|
||||||
|
|
||||||
const mergePairs = (target: KeyValue[], incoming: KeyValue[]): KeyValue[] => {
|
|
||||||
if (replace) return incoming;
|
|
||||||
const out = [...target];
|
|
||||||
for (const p of incoming) {
|
|
||||||
const i = out.findIndex((x) => x.key.toLowerCase() === p.key.toLowerCase());
|
|
||||||
if (i >= 0) out[i] = p;
|
|
||||||
else out.push(p);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (req) {
|
|
||||||
if (req.method && this.methods.includes(req.method)) {
|
|
||||||
a.method = req.method as VendorApi['method'];
|
|
||||||
done.push(`method ${req.method}`);
|
|
||||||
}
|
|
||||||
if (req.path && req.path !== '/') {
|
|
||||||
a.path = this.stripVendorBasePath(req.path);
|
|
||||||
done.push(`path ${a.path}`);
|
|
||||||
}
|
|
||||||
if (req.baseUrl) {
|
|
||||||
if (!this.form.baseUrl) {
|
|
||||||
this.form.baseUrl = req.baseUrl;
|
|
||||||
notes.push(`Base URL ${req.baseUrl} was put on the Connection tab - save it there.`);
|
|
||||||
} else if (this.form.baseUrl.replace(/\/+$/, '') !== req.baseUrl) {
|
|
||||||
notes.push(
|
|
||||||
`The file's host (${req.baseUrl}) is not this vendor's base URL (${this.form.baseUrl}); only the path was used.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (req.queryParams.length) {
|
|
||||||
a.queryParams = mergePairs(a.queryParams, req.queryParams);
|
|
||||||
done.push(`${req.queryParams.length} query parameter(s)`);
|
|
||||||
}
|
|
||||||
if (req.headers.length) {
|
|
||||||
a.headers = mergePairs(a.headers, req.headers);
|
|
||||||
done.push(`${req.headers.length} header(s)`);
|
|
||||||
}
|
|
||||||
if (req.contentType) {
|
|
||||||
a.contentType = Object.keys(API_CONTENT_TYPES).includes(req.contentType) ? req.contentType : a.contentType;
|
|
||||||
done.push(`content type ${a.contentType}`);
|
|
||||||
}
|
|
||||||
if (req.body !== undefined) {
|
|
||||||
a.requestBody = req.body;
|
|
||||||
done.push('request payload');
|
|
||||||
}
|
|
||||||
if (!a.name.trim() && a.path) {
|
|
||||||
const last =
|
|
||||||
a.path
|
|
||||||
.split('/')
|
|
||||||
.filter((s) => s && !s.includes('{'))
|
|
||||||
.pop() ?? 'API';
|
|
||||||
a.name = last.charAt(0).toUpperCase() + last.slice(1).replace(/[-_]+/g, ' ');
|
|
||||||
}
|
|
||||||
this.pathChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res) {
|
|
||||||
const failure = res.status !== undefined && (res.status < 200 || res.status > 299);
|
|
||||||
if (res.status !== undefined && !failure) {
|
|
||||||
a.successCodes = String(res.status);
|
|
||||||
done.push(`success code ${res.status}`);
|
|
||||||
}
|
|
||||||
if (res.headers.length) {
|
|
||||||
a.responseHeaders = mergePairs(a.responseHeaders, res.headers);
|
|
||||||
done.push(`${res.headers.length} response header(s)`);
|
|
||||||
}
|
|
||||||
if (res.body !== undefined) {
|
|
||||||
if (failure) {
|
|
||||||
a.errorBody = res.body;
|
|
||||||
done.push(`error body (HTTP ${res.status})`);
|
|
||||||
} else {
|
|
||||||
a.responseBody = res.body;
|
|
||||||
done.push('response body');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (res.errorBody !== undefined) {
|
|
||||||
a.errorBody = res.errorBody;
|
|
||||||
done.push('error body');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.importGenerate) {
|
|
||||||
const addRows = (side: 'request' | 'response', body: string) => {
|
|
||||||
const reading = !this.fromIsCbs(side);
|
|
||||||
const rows = reading ? guessResponseRows(body, this.vendor!.code) : guessRequestRows(body);
|
|
||||||
if (!rows.length) return;
|
|
||||||
const target = replace ? [] : this.rowsOf(side);
|
|
||||||
const have = new Set(target.map((r) => r.to));
|
|
||||||
const merged = [
|
|
||||||
...target,
|
|
||||||
...rows.filter((r) => !have.has(r.to))
|
|
||||||
];
|
|
||||||
if (side === 'request') a.requestRows = merged;
|
|
||||||
else a.responseRows = merged;
|
|
||||||
done.push(`${rows.length} ${side === 'request' ? 'request' : 'response'} mapping row(s)`);
|
|
||||||
};
|
|
||||||
if (req?.body !== undefined) addRows('request', a.requestBody);
|
|
||||||
if (res?.body !== undefined && !(res.status !== undefined && (res.status < 200 || res.status > 299)))
|
|
||||||
addRows('response', a.responseBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.importOpen = false;
|
|
||||||
this.error = null;
|
|
||||||
this.info = `Read from ${[
|
|
||||||
req?.source,
|
|
||||||
res?.source
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' + ')}: ${done.join(', ') || 'nothing new'}. ${notes.join(' ')} Review each tab, then Save API.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A path read from a full URL may repeat the path already in the vendor's base URL. */
|
|
||||||
private stripVendorBasePath(path: string): string {
|
|
||||||
const base = String(this.form.baseUrl ?? '');
|
|
||||||
const m = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(\/.+)$/.exec(base.replace(/\/+$/, ''));
|
|
||||||
if (m && path.startsWith(m[1] + '/')) return path.slice(m[1].length);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── test ──
|
|
||||||
|
|
||||||
get testApi(): VendorApi | null {
|
|
||||||
return this.vendor?.apis.find((x) => x.id === this.testApiId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
fillTestBody(): void {
|
|
||||||
this.testBody = this.testApi?.requestBody ?? '{}';
|
|
||||||
this.testResult = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get testPreview(): RequestPreview | null {
|
|
||||||
const a = this.testApi;
|
|
||||||
return this.vendor && a ? buildRequestPreview(this.vendor, { ...a, requestBody: this.testBody }) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
runTest(): void {
|
|
||||||
if (!this.vendor || !this.testApiId) return;
|
|
||||||
this.testing = true;
|
|
||||||
this.error = null;
|
|
||||||
this.service.testApi(this.vendor.id, this.testApiId, this.testBody).subscribe({
|
|
||||||
next: (log) => {
|
|
||||||
this.testing = false;
|
|
||||||
this.testResult = log;
|
|
||||||
this.loadCalls();
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.testing = false;
|
|
||||||
this.error = e?.error?.defaultUserMessage ?? 'Test failed.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="cic-page">
|
|
||||||
<div class="cic-head">
|
|
||||||
<div>
|
|
||||||
<h1>API Management</h1>
|
|
||||||
<p>
|
|
||||||
Connect any API service to the CBS the same way: <b>Vendor → Work → API set</b>. <b>IN</b> = data comes into the
|
|
||||||
CBS from the vendor (CIC alerts, delivery reports, replies). <b>OUT</b> = the CBS calls the vendor (send an SMS
|
|
||||||
or WhatsApp message).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="cic-actions">
|
|
||||||
<a class="cic-btn" routerLink="/api-management/logs">API call logs</a>
|
|
||||||
<button class="cic-btn primary" (click)="startAdd()">+ Add vendor</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (error && !adding) {
|
|
||||||
<div class="cic-alert error">{{ error }}</div>
|
|
||||||
}
|
|
||||||
@if (info) {
|
|
||||||
<div class="cic-alert success">{{ info }}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cic-grid cols-4" style="margin-bottom: 16px">
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Vendors</div>
|
|
||||||
<div class="v">{{ vendors.length }}</div>
|
|
||||||
<div class="s">{{ activeVendors }} active</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">APIs</div>
|
|
||||||
<div class="v">{{ totalIn + totalOut }}</div>
|
|
||||||
<div class="s">{{ totalIn }} IN · {{ totalOut }} OUT</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Calls in 24 hours</div>
|
|
||||||
<div class="v">{{ summary.callsToday }}</div>
|
|
||||||
<div class="s">both directions</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Failed in 24 hours</div>
|
|
||||||
<div class="v" [class.tone-bad]="summary.failedToday > 0">{{ summary.failedToday }}</div>
|
|
||||||
<div class="s">see the call logs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>Vendors</h2>
|
|
||||||
@if (loading && !vendors.length) {
|
|
||||||
<div class="cic-empty">Loading…</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Vendor</th>
|
|
||||||
<th>Work</th>
|
|
||||||
<th>APIs</th>
|
|
||||||
<th>Environment</th>
|
|
||||||
<th>24 h calls</th>
|
|
||||||
<th>Last call</th>
|
|
||||||
<th>Active</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (v of vendors; track v.id) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<b>{{ v.name }}</b>
|
|
||||||
<div class="muted cic-mono" style="font-size: 11px">{{ v.code }}</div>
|
|
||||||
</td>
|
|
||||||
<td style="max-width: 340px; white-space: normal">{{ v.work }}</td>
|
|
||||||
<td>
|
|
||||||
@if (count(v, 'IN')) {
|
|
||||||
<span class="cic-badge brand">IN × {{ count(v, 'IN') }}</span>
|
|
||||||
}
|
|
||||||
@if (count(v, 'OUT')) {
|
|
||||||
<span class="cic-badge ok">OUT × {{ count(v, 'OUT') }}</span>
|
|
||||||
}
|
|
||||||
@if (!v.apis.length) {
|
|
||||||
<span class="muted">none yet</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="cic-badge"
|
|
||||||
[class.warn]="v.environment === 'SIMULATOR'"
|
|
||||||
[class.good]="v.environment === 'PRODUCTION'"
|
|
||||||
>{{ v.environment }}</span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ calls(v).calls }}
|
|
||||||
@if (calls(v).failed) {
|
|
||||||
<span class="tone-bad" style="font-size: 12px"> · {{ calls(v).failed }} failed</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>{{ v.lastCallOn ? (v.lastCallOn | slice: 0 : 16) : '—' }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-switch">
|
|
||||||
<input type="checkbox" [checked]="v.active" (change)="toggle(v, $event)" />
|
|
||||||
<span></span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style="white-space: nowrap">
|
|
||||||
<a class="cic-btn sm primary" [routerLink]="['/api-management/vendors', v.id]">Set up</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@if (v.links.length) {
|
|
||||||
<tr>
|
|
||||||
<td colspan="8" style="background: var(--cic-soft); padding: 6px 12px">
|
|
||||||
<span class="muted" style="font-size: 12px">{{ v.code }} also has:</span>
|
|
||||||
@for (l of v.links; track l.route) {
|
|
||||||
<a class="cic-btn sm" style="margin-left: 6px" [routerLink]="l.route">{{ l.label }}</a>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (adding) {
|
|
||||||
<div class="cic-modal-backdrop" (click)="adding = false">
|
|
||||||
<div class="cic-modal" (click)="$event.stopPropagation()">
|
|
||||||
<h3>Add an API vendor</h3>
|
|
||||||
@if (error) {
|
|
||||||
<div class="cic-alert error">{{ error }}</div>
|
|
||||||
}
|
|
||||||
<div class="cic-grid cols-1" style="gap: 12px">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Vendor name</label>
|
|
||||||
<input [(ngModel)]="form.name" placeholder="e.g. Aadhaar eKYC, Payment gateway, E-mail" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Work — what is this vendor used for?</label>
|
|
||||||
<textarea rows="3" [(ngModel)]="form.work" placeholder="e.g. Verify customer Aadhaar during KYC"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Environment</label>
|
|
||||||
<select [(ngModel)]="form.environment">
|
|
||||||
<option value="SIMULATOR">SIMULATOR — no network call</option>
|
|
||||||
<option value="UAT">UAT — vendor's test environment</option>
|
|
||||||
<option value="PRODUCTION">PRODUCTION — live</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="muted" style="font-size: 12px; margin: 12px 0 0">
|
|
||||||
Next you set up the connection and add its APIs, each marked IN or OUT.
|
|
||||||
</p>
|
|
||||||
<div class="foot">
|
|
||||||
<button class="cic-btn" (click)="adding = false">Cancel</button>
|
|
||||||
<button class="cic-btn primary" (click)="saveNew()" [disabled]="saving">
|
|
||||||
{{ saving ? 'Adding…' : 'Add and set up' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { CommonModule } from '@angular/common';
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Router, RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { ApiManagementService, ApiVendor } from '../api-management.service';
|
|
||||||
|
|
||||||
/** Every outside API service the CBS uses: vendor, its work, its APIs (IN / OUT) and whether it is switched on. */
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-api-vendors',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
RouterModule
|
|
||||||
],
|
|
||||||
templateUrl: './api-vendors.component.html',
|
|
||||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class ApiVendorsComponent implements OnInit {
|
|
||||||
private service = inject(ApiManagementService);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
vendors: ApiVendor[] = [];
|
|
||||||
summary: { callsToday: number; failedToday: number; perVendor: Record<number, { calls: number; failed: number }> } = {
|
|
||||||
callsToday: 0,
|
|
||||||
failedToday: 0,
|
|
||||||
perVendor: {}
|
|
||||||
};
|
|
||||||
loading = false;
|
|
||||||
error: string | null = null;
|
|
||||||
info: string | null = null;
|
|
||||||
|
|
||||||
adding = false;
|
|
||||||
saving = false;
|
|
||||||
form: { name: string; work: string; environment: ApiVendor['environment'] } = this.blankForm();
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
private blankForm() {
|
|
||||||
return { name: '', work: '', environment: 'SIMULATOR' as ApiVendor['environment'] };
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.service.vendors().subscribe((v) => {
|
|
||||||
this.vendors = v;
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
this.service.summary().subscribe((s) => (this.summary = s));
|
|
||||||
}
|
|
||||||
|
|
||||||
count(v: ApiVendor, direction: 'IN' | 'OUT'): number {
|
|
||||||
return v.apis.filter((a) => a.direction === direction).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalIn(): number {
|
|
||||||
return this.vendors.reduce((s, v) => s + this.count(v, 'IN'), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalOut(): number {
|
|
||||||
return this.vendors.reduce((s, v) => s + this.count(v, 'OUT'), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeVendors(): number {
|
|
||||||
return this.vendors.filter((v) => v.active).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
calls(v: ApiVendor): { calls: number; failed: number } {
|
|
||||||
return this.summary.perVendor[v.id] ?? { calls: 0, failed: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(v: ApiVendor, event: Event): void {
|
|
||||||
const want = (event.target as HTMLInputElement).checked;
|
|
||||||
(event.target as HTMLInputElement).checked = v.active;
|
|
||||||
this.error = null;
|
|
||||||
this.info = null;
|
|
||||||
this.service.saveVendor(v.id, { active: want }).subscribe({
|
|
||||||
next: (u) => {
|
|
||||||
v.active = u.active;
|
|
||||||
this.info = `${u.name} is now ${u.active ? 'active' : 'switched off'}.`;
|
|
||||||
},
|
|
||||||
error: (e) => (this.error = e?.error?.defaultUserMessage ?? 'Could not change the vendor.')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
startAdd(): void {
|
|
||||||
this.adding = true;
|
|
||||||
this.form = this.blankForm();
|
|
||||||
this.error = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveNew(): void {
|
|
||||||
this.saving = true;
|
|
||||||
this.error = null;
|
|
||||||
this.service.addVendor(this.form).subscribe({
|
|
||||||
next: (v) => {
|
|
||||||
this.saving = false;
|
|
||||||
this.adding = false;
|
|
||||||
this.router.navigate([
|
|
||||||
'/api-management/vendors',
|
|
||||||
v.id
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.saving = false;
|
|
||||||
this.error = e?.error?.defaultUserMessage ?? 'Could not add the vendor.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -73,13 +73,10 @@ import { InvestmentModule } from './investment/investment.module';
|
|||||||
import { NpaModule } from './npa/npa.module';
|
import { NpaModule } from './npa/npa.module';
|
||||||
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
||||||
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
||||||
import { CreditBureauModule } from './credit-bureau/credit-bureau.module';
|
|
||||||
import { ApiManagementModule } from './api-management/api-management.module';
|
|
||||||
import { DigitalModule } from './digital/digital.module';
|
import { DigitalModule } from './digital/digital.module';
|
||||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||||
import { AccountsModule } from './accounts/accounts.module';
|
import { AccountsModule } from './accounts/accounts.module';
|
||||||
import { ElectionModule } from './election/election.module';
|
import { ElectionModule } from './election/election.module';
|
||||||
import { LoanEligibilityModule } from './loan-eligibility/loan-eligibility.module';
|
|
||||||
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
|
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
|
||||||
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
|
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
|
||||||
import { PortalModule } from '@angular/cdk/portal';
|
import { PortalModule } from '@angular/cdk/portal';
|
||||||
@@ -192,13 +189,10 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
NpaModule,
|
NpaModule,
|
||||||
ReconciliationModule,
|
ReconciliationModule,
|
||||||
LoanOriginationModule,
|
LoanOriginationModule,
|
||||||
CreditBureauModule,
|
|
||||||
ApiManagementModule,
|
|
||||||
DigitalModule,
|
DigitalModule,
|
||||||
LetterMgmtModule,
|
LetterMgmtModule,
|
||||||
AccountsModule,
|
AccountsModule,
|
||||||
ElectionModule,
|
ElectionModule,
|
||||||
LoanEligibilityModule,
|
|
||||||
SponsorDDModule,
|
SponsorDDModule,
|
||||||
SponsorDDReconciliationModule,
|
SponsorDDReconciliationModule,
|
||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { of } from 'rxjs';
|
|||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
|
||||||
|
|
||||||
interface Denom {
|
interface Denom {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -55,7 +54,6 @@ export class CashTransferComponent implements OnInit {
|
|||||||
|
|
||||||
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
|
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
|
||||||
private requestsRef: MatDialogRef<unknown> | null = null;
|
private requestsRef: MatDialogRef<unknown> | null = null;
|
||||||
private authenticationService = inject(AuthenticationService);
|
|
||||||
|
|
||||||
notes: Denom[] = [];
|
notes: Denom[] = [];
|
||||||
coins: Denom[] = [];
|
coins: Denom[] = [];
|
||||||
@@ -141,14 +139,7 @@ export class CashTransferComponent implements OnInit {
|
|||||||
.getTellers()
|
.getTellers()
|
||||||
.pipe(catchError(() => of([] as any[])))
|
.pipe(catchError(() => of([] as any[])))
|
||||||
.subscribe((list) => {
|
.subscribe((list) => {
|
||||||
// The vault and its cash are physical, so a branch moves cash only from its own tellers. The
|
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||||
// backend lists every teller below the user's office (Head Office sees all branches), which is
|
|
||||||
// right for reports but wrong here.
|
|
||||||
const myOfficeId = this.authenticationService.getCredentials()?.officeId;
|
|
||||||
const own = (list || []).filter(
|
|
||||||
(t: any) => !myOfficeId || t.officeId == null || Number(t.officeId) === Number(myOfficeId)
|
|
||||||
);
|
|
||||||
this.tellers = own.map((t: any) => ({ id: t.id, name: t.name }));
|
|
||||||
if (this.tellers.length === 1) {
|
if (this.tellers.length === 1) {
|
||||||
this.onTellerSelect(this.tellers[0].id);
|
this.onTellerSelect(this.tellers[0].id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,12 +132,12 @@
|
|||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.br"
|
[(ngModel)]="row.br"
|
||||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
[disabled]="branches.length <= 1"
|
||||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
@for (b of branchSelectOptions; track b.id) {
|
@for (b of branches; track b.id) {
|
||||||
<option [ngValue]="b.id">{{ b.name }}</option>
|
<option [ngValue]="b.id">{{ b.label }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.acTyp"
|
[(ngModel)]="row.acTyp"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||||
>
|
>
|
||||||
@for (a of acTypOptionsFor(row); track a) {
|
@for (a of acTypOptionsFor(row); track a) {
|
||||||
<option [value]="a">{{ a }}</option>
|
<option [value]="a">{{ a }}</option>
|
||||||
@@ -267,7 +267,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ws-seg__btn ws-seg__btn--dr"
|
class="ws-seg__btn ws-seg__btn--dr"
|
||||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||||
[disabled]="isCreditOnly(row)"
|
[disabled]="isGlCreditOnly(row)"
|
||||||
(click)="row.dbCr = 'Dr'"
|
(click)="row.dbCr = 'Dr'"
|
||||||
>
|
>
|
||||||
DR
|
DR
|
||||||
@@ -722,24 +722,7 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="te-panel__body">
|
<div class="te-panel__body">
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">Branch</span
|
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
|
||||||
><span class="te-value">
|
|
||||||
@if (isOwnBranchAccount !== null) {
|
|
||||||
<span
|
|
||||||
class="te-branch-chip"
|
|
||||||
[class.te-branch-chip--mine]="isOwnBranchAccount"
|
|
||||||
[class.te-branch-chip--other]="!isOwnBranchAccount"
|
|
||||||
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
|
|
||||||
>
|
|
||||||
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
|
|
||||||
@if (!isOwnBranchAccount) {
|
|
||||||
<span class="te-branch-chip__tag">Other branch</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
{{ officeName }}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">A/c No.</span
|
<span class="te-label">A/c No.</span
|
||||||
@@ -1218,24 +1201,7 @@
|
|||||||
: '—')
|
: '—')
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
<span class="te-acs-kv"
|
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
|
||||||
><b>Branch</b>
|
|
||||||
@if (isOwnBranchAccount !== null) {
|
|
||||||
<span
|
|
||||||
class="te-branch-chip"
|
|
||||||
[class.te-branch-chip--mine]="isOwnBranchAccount"
|
|
||||||
[class.te-branch-chip--other]="!isOwnBranchAccount"
|
|
||||||
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
|
|
||||||
>
|
|
||||||
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
|
|
||||||
@if (!isOwnBranchAccount) {
|
|
||||||
<span class="te-branch-chip__tag">Other branch</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
{{ officeName }}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<span class="te-acs-kv"
|
<span class="te-acs-kv"
|
||||||
><b>Product</b>
|
><b>Product</b>
|
||||||
{{
|
{{
|
||||||
@@ -1626,12 +1592,12 @@
|
|||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.br"
|
[(ngModel)]="row.br"
|
||||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
[disabled]="branches.length <= 1"
|
||||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
@for (b of branchSelectOptions; track b.id) {
|
@for (b of branches; track b.id) {
|
||||||
<option [ngValue]="b.id">{{ b.name }}</option>
|
<option [ngValue]="b.id">{{ b.label }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1678,7 +1644,7 @@
|
|||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.acTyp"
|
[(ngModel)]="row.acTyp"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
(change)="isCreditOnly(row) && (row.dbCr = 'Cr')"
|
(change)="isGlCreditOnly(row) && (row.dbCr = 'Cr')"
|
||||||
>
|
>
|
||||||
@for (a of acTypOptionsFor(row); track a) {
|
@for (a of acTypOptionsFor(row); track a) {
|
||||||
<option [value]="a">{{ a }}</option>
|
<option [value]="a">{{ a }}</option>
|
||||||
@@ -1749,7 +1715,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ws-seg__btn ws-seg__btn--dr"
|
class="ws-seg__btn ws-seg__btn--dr"
|
||||||
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
[class.ws-seg__btn--active-dr]="row.dbCr === 'Dr'"
|
||||||
[disabled]="isCreditOnly(row)"
|
[disabled]="isGlCreditOnly(row)"
|
||||||
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
(click)="$event.stopPropagation(); row.dbCr = 'Dr'"
|
||||||
>
|
>
|
||||||
DR
|
DR
|
||||||
|
|||||||
@@ -3462,39 +3462,3 @@ select.te-cell {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The branch an account is held at, coloured by where the signed-in user works: green for their own branch, red for any other. */
|
|
||||||
.te-branch-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&--mine {
|
|
||||||
background: #dcfce7;
|
|
||||||
border-color: #4ade80;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--other {
|
|
||||||
background: #fca5a5;
|
|
||||||
border-color: #f87171;
|
|
||||||
color: #7f1d1d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-branch-chip__tag {
|
|
||||||
padding: 0 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgb(255 255 255 / 80%);
|
|
||||||
color: #7f1d1d;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import { Component, OnInit, OnDestroy, inject, Input, ElementRef, ViewChild } fr
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
|
|
||||||
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
|
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
|
||||||
import { forkJoin, of, Subject } from 'rxjs';
|
import { forkJoin, of, Subject } from 'rxjs';
|
||||||
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
@@ -1880,7 +1879,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||||
this.loadBranches();
|
this.loadBranches();
|
||||||
this.addRow();
|
this.addRow();
|
||||||
this.loadBranchOptions();
|
|
||||||
this.loadDenominations();
|
this.loadDenominations();
|
||||||
this.loadOpeningPosition();
|
this.loadOpeningPosition();
|
||||||
this.loadDayStatus();
|
this.loadDayStatus();
|
||||||
@@ -2346,20 +2344,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A Fixed Deposit row is credit-only on both the clerk and the cashier screens: an FD is funded once, with
|
|
||||||
* exactly its principal, and money leaves it only through its maturity or premature closure. The one
|
|
||||||
* exception is the clerk's own "Close this account" flow, which raises a closure request.
|
|
||||||
*/
|
|
||||||
isFdCreditOnly(row: TxnRow): boolean {
|
|
||||||
return row.acTyp === 'FD' && !this.pendingClosure;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** DR is not offered for this row (see isGlCreditOnly and isFdCreditOnly). */
|
|
||||||
isCreditOnly(row: TxnRow): boolean {
|
|
||||||
return this.isGlCreditOnly(row) || this.isFdCreditOnly(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
|
/** Called when the TYPE dropdown changes — sets sensible defaults for TXN TP and DR/CR */
|
||||||
/**
|
/**
|
||||||
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
|
* A cashier row that was filled from an approved token (withdrawal / account-closure) is cash
|
||||||
@@ -2712,8 +2696,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
branchNameFor(branchId: number): string {
|
branchNameFor(branchId: number): string {
|
||||||
const seen = this.seenBranches.find((b) => b.id === branchId);
|
|
||||||
if (seen) return seen.name;
|
|
||||||
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
|
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4332,7 +4314,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.accountDetails = acc;
|
this.accountDetails = acc;
|
||||||
this.resolveAccountBranch(acc, acTyp, token, this.selectedRow);
|
|
||||||
this.jointHolders = acc.jointHolders ?? [];
|
this.jointHolders = acc.jointHolders ?? [];
|
||||||
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
|
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
|
||||||
this.glParent = null;
|
this.glParent = null;
|
||||||
@@ -4949,98 +4930,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the account on screen is held at the branch the signed-in user works at: true for their own branch, false for
|
|
||||||
* another branch's, null while either branch is not known (the branch is then shown plain, uncoloured).
|
|
||||||
*/
|
|
||||||
get isOwnBranchAccount(): boolean | null {
|
|
||||||
const mine = this.authService.getCredentials()?.officeId;
|
|
||||||
const accountOffice = this.accountDetails?.officeId ?? this.clientDetails?.officeId;
|
|
||||||
if (mine == null || accountOffice == null) return null;
|
|
||||||
return Number(mine) === Number(accountOffice);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The bank's branches, for each row's BRANCH selector. Empty until /offices answers. */
|
|
||||||
branchOptions: { id: number; name: string }[] = [];
|
|
||||||
|
|
||||||
/** What the BRANCH selector offers: the real branches, or the old fixed list while they have not loaded. */
|
|
||||||
get branchSelectOptions(): { id: number; name: string }[] {
|
|
||||||
const base = this.branchOptions.length ? this.branchOptions : TransactionEntryComponent.LEGACY_BRANCHES;
|
|
||||||
const merged = base.map((branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? branch);
|
|
||||||
for (const seen of this.seenBranches) {
|
|
||||||
if (!merged.some((branch) => branch.id === seen.id)) merged.push(seen);
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Branches learned from the accounts picked on this screen; their names are real, whatever the office list says. */
|
|
||||||
private seenBranches: { id: number; name: string }[] = [];
|
|
||||||
|
|
||||||
private static readonly LEGACY_BRANCHES = [
|
|
||||||
{ id: 1, name: '001 · Main' },
|
|
||||||
{ id: 2, name: '002 · M.G. Road' },
|
|
||||||
{ id: 14, name: '014 · Station Rd' },
|
|
||||||
{ id: 27, name: '027 · Industrial' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The bank's branches for the BRANCH selector. Listing offices needs the READ offices permission, which a cashier or
|
|
||||||
* clerk may not hold, so a refusal must not raise an error popup: the selector then shows the branches this screen has
|
|
||||||
* learned from the accounts on it (and the old fixed list until then).
|
|
||||||
*/
|
|
||||||
private loadBranchOptions(): void {
|
|
||||||
this.http
|
|
||||||
.get<any[]>('/offices', { context: new HttpContext().set(SILENT_HTTP_ERROR, true) })
|
|
||||||
.pipe(catchError(() => of([])))
|
|
||||||
.subscribe((offices) => {
|
|
||||||
this.branchOptions = (offices ?? []).map((o: any) => ({ id: o.id, name: o.name }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The branch an account is held at. The savings, FD and RD detail responses carry no office, so the screen fell back
|
|
||||||
* to the client's own branch and an account opened at another branch read as the client's. The client's account list
|
|
||||||
* names each account's branch, so it is read from there. A current account's own response already carries it.
|
|
||||||
*/
|
|
||||||
private resolveAccountBranch(acc: any, acTyp: string, token: number, row: TxnRow | null | undefined): void {
|
|
||||||
if (acc?.officeId != null && acc?.officeName) {
|
|
||||||
this.adoptAccountBranch({ id: Number(acc.officeId), name: String(acc.officeName) }, row);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const type = (acTyp ?? '').toUpperCase();
|
|
||||||
if (!acc?.id || !acc?.clientId || ![
|
|
||||||
'SD',
|
|
||||||
'FD',
|
|
||||||
'RD',
|
|
||||||
'CA'
|
|
||||||
].includes(type)) return;
|
|
||||||
this.http
|
|
||||||
.get<any>(`/clients/${acc.clientId}/accounts`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) })
|
|
||||||
.pipe(catchError(() => of(null)))
|
|
||||||
.subscribe((res) => {
|
|
||||||
// stale: another account was picked while this was loading
|
|
||||||
if (token !== this.searchToken || this.accountDetails?.id !== acc.id) return;
|
|
||||||
const list: any[] = (type === 'CA' ? res?.currentAccounts : res?.savingsAccounts) ?? [];
|
|
||||||
const match = list.find((a: any) => a.id === acc.id);
|
|
||||||
if (match?.officeId != null && match?.officeName) {
|
|
||||||
this.adoptAccountBranch({ id: Number(match.officeId), name: String(match.officeName) }, row);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private adoptAccountBranch(branch: { id: number; name: string }, row: TxnRow | null | undefined): void {
|
|
||||||
this.accountDetails = { ...this.accountDetails, officeId: branch.id, officeName: branch.name };
|
|
||||||
// /offices lists only the signed-in user's own branches, or nothing for a user who may not read offices; the account's
|
|
||||||
// branch must still be selectable, under its real name
|
|
||||||
if (!this.seenBranches.some((b) => b.id === branch.id)) {
|
|
||||||
this.seenBranches = [
|
|
||||||
...this.seenBranches,
|
|
||||||
branch
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (row) row.br = branch.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
get openingDate(): string {
|
get openingDate(): string {
|
||||||
const raw =
|
const raw =
|
||||||
this.accountDetails?.timeline?.submittedOnDate ??
|
this.accountDetails?.timeline?.submittedOnDate ??
|
||||||
@@ -5383,20 +5272,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSave(): void {
|
onSave(): void {
|
||||||
// Money never leaves a Fixed Deposit from this screen, clerk or cashier: no withdrawal slip, payment,
|
|
||||||
// collection or transfer debits an FD. It is paid out only through its maturity or premature closure
|
|
||||||
// (the clerk's "Close this account" flow). The DR button is already disabled for FD rows (isFdCreditOnly);
|
|
||||||
// this also catches a row set to DR by its TYPE, such as a Withdrawal Slip.
|
|
||||||
const fdDebitRows = this.rows.filter((r) => this.isFdCreditOnly(r) && r.dbCr === 'Dr');
|
|
||||||
if (fdDebitRows.length > 0) {
|
|
||||||
this.showAlert('error', 'Fixed Deposit Cannot Be Debited', [
|
|
||||||
`FD account(s) ${fdDebitRows.map((r) => r.acNo).join(', ')} cannot be debited or withdrawn from.`,
|
|
||||||
'Money leaves a Fixed Deposit only through its maturity or premature closure.',
|
|
||||||
'An FD can only be credited — once, with exactly its principal.'
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
|
// Demand drafts commit here like everything else on the worksheet. Checked first because a DD
|
||||||
// row shares its shape with the ordinary entries below — without this it would fall through and
|
// row shares its shape with the ordinary entries below — without this it would fall through and
|
||||||
// post as a plain transaction, with the draft never raised and the charges silently dropped.
|
// post as a plain transaction, with the draft never raised and the charges silently dropped.
|
||||||
@@ -5474,9 +5349,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bank transfer: savings (SD) or current (CA/CB) accounts on either side, plus a Fixed Deposit (FD) on
|
// Bank transfer: only savings (SD) or current (CA/CB) accounts allowed
|
||||||
// the CR side only -- funding it from the customer's own account. The server checks the funding rules
|
|
||||||
// (first and only funding, exactly the principal, the source account's owner and available balance).
|
|
||||||
const allowedTypes = new Set([
|
const allowedTypes = new Set([
|
||||||
'SD',
|
'SD',
|
||||||
'CA',
|
'CA',
|
||||||
@@ -5484,13 +5357,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
]);
|
]);
|
||||||
const invalidAccounts = [
|
const invalidAccounts = [
|
||||||
...drRows,
|
...drRows,
|
||||||
...crRows.filter((r) => r.acTyp !== 'FD')
|
...crRows
|
||||||
].filter((r) => !allowedTypes.has(r.acTyp));
|
].filter((r) => !allowedTypes.has(r.acTyp));
|
||||||
if (invalidAccounts.length > 0) {
|
if (invalidAccounts.length > 0) {
|
||||||
this.showAlert('error', 'Invalid Account Type', [
|
this.showAlert('error', 'Invalid Account Type', [
|
||||||
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
|
`Account type(s) not allowed in Bank Transfer: ${invalidAccounts.map((r) => r.acTyp + ' (' + r.acNo + ')').join(', ')}`,
|
||||||
'Debit side: only Savings (SD) and Current (CA) accounts. Credit side: SD, CA or a Fixed Deposit (FD).',
|
'Only Savings (SD) and Current (CA) accounts are permitted.',
|
||||||
'GL and SHR accounts are not allowed — use Disbursement/Collection instead.',
|
'GL, FD and SHR accounts are not allowed — use Disbursement/Collection instead.',
|
||||||
'RD installments are paid through a cashier Receipt or Auto Debit.'
|
'RD installments are paid through a cashier Receipt or Auto Debit.'
|
||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -102,27 +102,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Money moving between branches: the approver must see it before approving -->
|
|
||||||
@if (isInterBranch) {
|
|
||||||
<div class="dri-interbranch">
|
|
||||||
<mat-icon>alt_route</mat-icon>
|
|
||||||
<div>
|
|
||||||
<div class="dri-interbranch__title">Inter-branch transfer</div>
|
|
||||||
<div class="dri-interbranch__text">
|
|
||||||
@if (branchNames(drEntries) && branchNames(crEntries)) {
|
|
||||||
Money moves from <strong>{{ branchNames(drEntries) }}</strong> to
|
|
||||||
<strong>{{ branchNames(crEntries) }}</strong
|
|
||||||
>.
|
|
||||||
} @else {
|
|
||||||
These accounts are held at different branches: <strong>{{ branchNames(creditEntries) }}</strong
|
|
||||||
>.
|
|
||||||
}
|
|
||||||
Each account is posted in its own branch's books. Check the accounts before approving.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Bank Transfer summary card (replaces GL card) -->
|
<!-- Bank Transfer summary card (replaces GL card) -->
|
||||||
@if (isBankTransferMode) {
|
@if (isBankTransferMode) {
|
||||||
<div class="dri-card">
|
<div class="dri-card">
|
||||||
@@ -175,7 +154,6 @@
|
|||||||
<th>Account No</th>
|
<th>Account No</th>
|
||||||
<th>Account Name</th>
|
<th>Account Name</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Branch</th>
|
|
||||||
<th class="num">{{ isCollectionMode ? 'Amount (DR)' : 'Amount (CR)' }}</th>
|
<th class="num">{{ isCollectionMode ? 'Amount (DR)' : 'Amount (CR)' }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -188,16 +166,11 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
|
||||||
entry.officeName || '—'
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
<tr class="dri-cr-total-row">
|
<tr class="dri-cr-total-row">
|
||||||
<td colspan="5" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
|
<td colspan="4" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
|
||||||
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -219,7 +192,6 @@
|
|||||||
<th>Account No</th>
|
<th>Account No</th>
|
||||||
<th>Account Name</th>
|
<th>Account Name</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Branch</th>
|
|
||||||
<th class="num">Amount (DR)</th>
|
<th class="num">Amount (DR)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -232,16 +204,11 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
|
||||||
entry.officeName || '—'
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
<tr class="dri-cr-total-row">
|
<tr class="dri-cr-total-row">
|
||||||
<td colspan="5" class="dri-cr-total-label">Total Debit</td>
|
<td colspan="4" class="dri-cr-total-label">Total Debit</td>
|
||||||
<td class="num dri-cr-total-amount">₹{{ totalDebitAmount() | number: '1.2-2' }}</td>
|
<td class="num dri-cr-total-amount">₹{{ totalDebitAmount() | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -261,7 +228,6 @@
|
|||||||
<th>Account No</th>
|
<th>Account No</th>
|
||||||
<th>Account Name</th>
|
<th>Account Name</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Branch</th>
|
|
||||||
<th class="num">Amount (CR)</th>
|
<th class="num">Amount (CR)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -274,16 +240,11 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
|
||||||
entry.officeName || '—'
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
<tr class="dri-cr-total-row">
|
<tr class="dri-cr-total-row">
|
||||||
<td colspan="5" class="dri-cr-total-label">Total Credit</td>
|
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
|
||||||
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -534,54 +534,3 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
|
|||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A leg's branch. Amber on every leg when the request moves money between branches, neutral otherwise. */
|
|
||||||
.dri-branch-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #475569;
|
|
||||||
|
|
||||||
&--cross {
|
|
||||||
background: #fffbeb;
|
|
||||||
border-color: #fcd34d;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Shown above the summary when the accounts are at different branches. */
|
|
||||||
.dri-interbranch {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px solid #fcd34d;
|
|
||||||
border-left: 4px solid #f59e0b;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fffbeb;
|
|
||||||
color: #78350f;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__text {
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ interface CreditEntry {
|
|||||||
accountType: string;
|
accountType: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
dbCr?: string;
|
dbCr?: string;
|
||||||
/** The branch the account is held at; absent for a GL leg or when the server could not resolve it. */
|
|
||||||
officeId?: number | null;
|
|
||||||
officeName?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -170,19 +167,6 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
return this.creditEntries.filter((e) => e.dbCr !== 'Dr');
|
return this.creditEntries.filter((e) => e.dbCr !== 'Dr');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* True when the accounts in this request are held at more than one branch, so money moves between branches. The
|
|
||||||
* approver has to see that before approving: it is booked in each branch's own books.
|
|
||||||
*/
|
|
||||||
get isInterBranch(): boolean {
|
|
||||||
return new Set(this.creditEntries.filter((e) => e.officeId != null).map((e) => e.officeId)).size > 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Distinct branch names among the given legs, in order, e.g. for "Head Office" or "Head Office, bhopal". */
|
|
||||||
branchNames(entries: CreditEntry[]): string {
|
|
||||||
return [...new Set(entries.map((e) => e.officeName).filter((name): name is string => !!name))].join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
totalCreditAmount(): number {
|
totalCreditAmount(): number {
|
||||||
const entries = this.isCollectionMode ? this.drEntries : this.crEntries;
|
const entries = this.isCollectionMode ? this.drEntries : this.crEntries;
|
||||||
return entries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
|
return entries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
|
||||||
|
|||||||
+1
-11
@@ -930,10 +930,6 @@
|
|||||||
createClientForm.get('mobileNo')?.hasError('pattern') && createClientForm.get('mobileNo')?.touched
|
createClientForm.get('mobileNo')?.hasError('pattern') && createClientForm.get('mobileNo')?.touched
|
||||||
) {
|
) {
|
||||||
<mat-error>Enter valid 10-digit number (starts with 6-9)</mat-error>
|
<mat-error>Enter valid 10-digit number (starts with 6-9)</mat-error>
|
||||||
} @else if (createClientForm.get('mobileNo')?.hasError('contactTaken')) {
|
|
||||||
<mat-error
|
|
||||||
>This mobile number already belongs to another customer. Please enter a different number.</mat-error
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
@@ -944,11 +940,7 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
type="button"
|
type="button"
|
||||||
class="verify-btn"
|
class="verify-btn"
|
||||||
[disabled]="
|
[disabled]="readOnly || createClientForm.get('mobileNo')?.value?.length !== 10"
|
||||||
readOnly ||
|
|
||||||
createClientForm.get('mobileNo')?.value?.length !== 10 ||
|
|
||||||
createClientForm.get('mobileNo')?.hasError('contactTaken')
|
|
||||||
"
|
|
||||||
(click)="verifyWithDIP()"
|
(click)="verifyWithDIP()"
|
||||||
>
|
>
|
||||||
<fa-icon icon="shield-alt" class="m-r-10"></fa-icon>
|
<fa-icon icon="shield-alt" class="m-r-10"></fa-icon>
|
||||||
@@ -1016,8 +1008,6 @@
|
|||||||
createClientForm.get('emailAddress')?.hasError('required') && createClientForm.get('emailAddress')?.touched
|
createClientForm.get('emailAddress')?.hasError('required') && createClientForm.get('emailAddress')?.touched
|
||||||
) {
|
) {
|
||||||
<mat-error>Email is <strong>required</strong></mat-error>
|
<mat-error>Email is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.get('emailAddress')?.hasError('contactTaken')) {
|
|
||||||
<mat-error>This email already belongs to another customer. Please enter a different email.</mat-error>
|
|
||||||
} @else if (createClientForm.get('emailAddress')?.invalid && createClientForm.get('emailAddress')?.touched) {
|
} @else if (createClientForm.get('emailAddress')?.invalid && createClientForm.get('emailAddress')?.touched) {
|
||||||
<mat-error>Enter a valid email (e.g. name@example.com)</mat-error>
|
<mat-error>Enter a valid email (e.g. name@example.com)</mat-error>
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-47
@@ -27,16 +27,15 @@ import {
|
|||||||
UntypedFormControl,
|
UntypedFormControl,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
ValidatorFn,
|
ValidatorFn,
|
||||||
AsyncValidatorFn,
|
|
||||||
AbstractControl,
|
AbstractControl,
|
||||||
ValidationErrors
|
ValidationErrors
|
||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
import { Subject, Subscription, merge, of, timer } from 'rxjs';
|
import { Subject, Subscription, merge } from 'rxjs';
|
||||||
import { catchError, filter, map, switchMap, takeUntil } from 'rxjs/operators';
|
import { filter, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
||||||
import { toPersonNameCase, toProperNameCase } from 'app/clients/utils/name-case.util';
|
import { toProperNameCase } from 'app/clients/utils/name-case.util';
|
||||||
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
@@ -937,29 +936,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A mobile number or email that already belongs to another customer. This is a different person using a contact
|
|
||||||
* that is taken (a PAN or Aadhaar match is what means the same customer), so the answer is to enter another one.
|
|
||||||
* Runs only once the format validators pass, and a new keystroke cancels the previous call, which is what
|
|
||||||
* debounces it. The client being edited is excluded so its own number is not flagged.
|
|
||||||
*/
|
|
||||||
private contactTakenValidator(field: 'mobileNo' | 'emailAddress'): AsyncValidatorFn {
|
|
||||||
return (control: AbstractControl) => {
|
|
||||||
const value = (control.value ?? '').toString().trim();
|
|
||||||
if (!value) {
|
|
||||||
return of(null);
|
|
||||||
}
|
|
||||||
return timer(400).pipe(
|
|
||||||
switchMap(() =>
|
|
||||||
this.clientService.lookupClientIdentity({ [field]: value, excludeClientId: this.clientData?.id ?? null })
|
|
||||||
),
|
|
||||||
map((result) => (result?.contactConflicts?.[field] ? { contactTaken: true } : null)),
|
|
||||||
// A failed lookup must not block the form: the server still refuses a duplicate when the client is saved.
|
|
||||||
catchError(() => of(null))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the client form.
|
* Creates the client form.
|
||||||
*/
|
*/
|
||||||
@@ -993,8 +969,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.required,
|
Validators.required,
|
||||||
Validators.pattern(/^[6-9]\d{9}$/),
|
Validators.pattern(/^[6-9]\d{9}$/),
|
||||||
Validators.maxLength(10)
|
Validators.maxLength(10)
|
||||||
],
|
]
|
||||||
this.contactTakenValidator('mobileNo')
|
|
||||||
],
|
],
|
||||||
alternateMobileNo: [
|
alternateMobileNo: [
|
||||||
'',
|
'',
|
||||||
@@ -1011,8 +986,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.email,
|
Validators.email,
|
||||||
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
|
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
|
||||||
Validators.maxLength(100)
|
Validators.maxLength(100)
|
||||||
],
|
]
|
||||||
this.contactTakenValidator('emailAddress')
|
|
||||||
],
|
],
|
||||||
dateOfBirth: [
|
dateOfBirth: [
|
||||||
'',
|
'',
|
||||||
@@ -1482,10 +1456,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
const control = this.createClientForm.get(controlName);
|
const control = this.createClientForm.get(controlName);
|
||||||
if (control && control.value) {
|
if (control && control.value) {
|
||||||
const originalValue = control.value;
|
const originalValue = control.value;
|
||||||
// An entity's full name keeps its typed spelling (matched against the PAN name); a person's
|
const capitalized = toProperNameCase(originalValue);
|
||||||
// name parts are title-cased, the same as the Full Name line shows them.
|
|
||||||
const capitalized =
|
|
||||||
controlName === 'fullname' ? toProperNameCase(originalValue) : toPersonNameCase(originalValue);
|
|
||||||
if (originalValue !== capitalized) {
|
if (originalValue !== capitalized) {
|
||||||
control.setValue(capitalized);
|
control.setValue(capitalized);
|
||||||
}
|
}
|
||||||
@@ -1954,11 +1925,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
|
|
||||||
/** Returns capitalized full name preview from first + middle + last */
|
/** Returns capitalized full name preview from first + middle + last */
|
||||||
get fullNamePreview(): string {
|
get fullNamePreview(): string {
|
||||||
|
const capitalize = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : '');
|
||||||
const salutationId = this.createClientForm?.get('salutationId')?.value;
|
const salutationId = this.createClientForm?.get('salutationId')?.value;
|
||||||
const salutation = this.salutationOptions?.find((s: any) => s.id === salutationId)?.name || '';
|
const salutation = this.salutationOptions?.find((s: any) => s.id === salutationId)?.name || '';
|
||||||
const first = toPersonNameCase(this.createClientForm?.get('firstname')?.value);
|
const first = capitalize(this.createClientForm?.get('firstname')?.value || '');
|
||||||
const middle = toPersonNameCase(this.createClientForm?.get('middlename')?.value);
|
const middle = capitalize(this.createClientForm?.get('middlename')?.value || '');
|
||||||
const last = toPersonNameCase(this.createClientForm?.get('lastname')?.value);
|
const last = capitalize(this.createClientForm?.get('lastname')?.value || '');
|
||||||
const full = [
|
const full = [
|
||||||
first,
|
first,
|
||||||
middle,
|
middle,
|
||||||
@@ -2388,15 +2360,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
if (generalDetails.fullname) {
|
if (generalDetails.fullname) {
|
||||||
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
|
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
|
||||||
}
|
}
|
||||||
for (const part of [
|
|
||||||
'firstname',
|
|
||||||
'middlename',
|
|
||||||
'lastname'
|
|
||||||
]) {
|
|
||||||
if (generalDetails[part]) {
|
|
||||||
generalDetails[part] = toPersonNameCase(generalDetails[part]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generalDetails.submittedOnDate instanceof Date) {
|
if (generalDetails.submittedOnDate instanceof Date) {
|
||||||
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
|
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
|
||||||
|
|||||||
-211
@@ -1,211 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<section class="identity-check" [class.identity-check--blocking]="status === 'matched' || status === 'conflict'">
|
|
||||||
<header class="identity-check__header">
|
|
||||||
<mat-icon class="identity-check__icon">fingerprint</mat-icon>
|
|
||||||
<div>
|
|
||||||
<h3 class="identity-check__title">Existing customer check</h3>
|
|
||||||
<p class="identity-check__hint">
|
|
||||||
@if (isEntity) {
|
|
||||||
Enter the company's GSTIN or PAN. If either is already on file in any branch, it is the same customer and a
|
|
||||||
new one is not created.
|
|
||||||
} @else {
|
|
||||||
Enter PAN or Aadhaar. If either is already on file in any branch, it is the same customer and a new one is not
|
|
||||||
created. Nothing found? What you enter here is filled into the KYC step for you.
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@if (form) {
|
|
||||||
<form [formGroup]="form" class="identity-check__fields">
|
|
||||||
@if (isEntity) {
|
|
||||||
<mat-form-field appearance="outline" class="identity-check__field">
|
|
||||||
<mat-label>GSTIN</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
formControlName="gstin"
|
|
||||||
class="identity-check__upper"
|
|
||||||
maxlength="15"
|
|
||||||
mifosxRestrictInput="gstin"
|
|
||||||
[errorStateMatcher]="errorMatcher"
|
|
||||||
placeholder="e.g. 22AAAAA0000A1Z5"
|
|
||||||
(input)="capitalise('gstin', $event)"
|
|
||||||
/>
|
|
||||||
@if (form.get('gstin')?.hasError('pattern')) {
|
|
||||||
<mat-error>Invalid GSTIN. It has 15 characters, like 22AAAAA0000A1Z5</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="identity-check__field">
|
|
||||||
<mat-label>{{ isEntity ? 'Company PAN' : 'PAN' }}</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
formControlName="pan"
|
|
||||||
class="identity-check__upper"
|
|
||||||
maxlength="10"
|
|
||||||
mifosxRestrictInput="pan"
|
|
||||||
[errorStateMatcher]="errorMatcher"
|
|
||||||
placeholder="e.g. ABCDE1234F"
|
|
||||||
(input)="capitalise('pan', $event)"
|
|
||||||
/>
|
|
||||||
@if (form.get('pan')?.hasError('pattern')) {
|
|
||||||
<mat-error>Invalid PAN. Use 5 letters, 4 digits, 1 letter, like ABCDE1234F</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
@if (!isEntity) {
|
|
||||||
<mat-form-field appearance="outline" class="identity-check__field">
|
|
||||||
<mat-label>Aadhaar</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
formControlName="aadhaar"
|
|
||||||
maxlength="12"
|
|
||||||
inputmode="numeric"
|
|
||||||
mifosxRestrictInput="digits"
|
|
||||||
[errorStateMatcher]="errorMatcher"
|
|
||||||
placeholder="12 digits"
|
|
||||||
/>
|
|
||||||
@if (form.get('aadhaar')?.hasError('pattern')) {
|
|
||||||
<mat-error>Invalid Aadhaar. It must be exactly 12 digits</mat-error>
|
|
||||||
} @else {
|
|
||||||
<mat-hint>Only a fingerprint of this number is sent, never the number</mat-hint>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
|
|
||||||
@switch (status) {
|
|
||||||
@case ('checking') {
|
|
||||||
<div class="identity-check__status identity-check__status--info">
|
|
||||||
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
|
|
||||||
Checking all branches…
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('not-found') {
|
|
||||||
<div class="identity-check__status identity-check__status--ok">
|
|
||||||
<mat-icon>check_circle</mat-icon>
|
|
||||||
<span>
|
|
||||||
No existing customer found. You can continue; these details will be filled into the KYC step so you do not
|
|
||||||
type them again.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('error') {
|
|
||||||
<div class="identity-check__status identity-check__status--warn">
|
|
||||||
<mat-icon>warning</mat-icon>
|
|
||||||
<span>
|
|
||||||
Could not check right now. You can continue; the PAN, Aadhaar and GSTIN are checked again when the KYC is
|
|
||||||
saved.
|
|
||||||
</span>
|
|
||||||
<button mat-button type="button" (click)="recheck()">Check again</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('conflict') {
|
|
||||||
<div class="identity-check__status identity-check__status--error">
|
|
||||||
<mat-icon>report</mat-icon>
|
|
||||||
<span>
|
|
||||||
These details belong to <strong>different customers</strong>. Check what was typed. If they are correct, ask a
|
|
||||||
manager to review before continuing.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@for (m of matches; track m.clientId) {
|
|
||||||
<div class="identity-check__card">
|
|
||||||
<strong>{{ m.displayName }}</strong>
|
|
||||||
<span class="identity-check__meta">CIF {{ m.cifNumber }} · {{ m.officeName }}</span>
|
|
||||||
<span class="identity-check__chips">
|
|
||||||
@for (by of m.matchedBy; track by) {
|
|
||||||
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@case ('matched') {
|
|
||||||
@if (match; as m) {
|
|
||||||
<div class="identity-check__status identity-check__status--error">
|
|
||||||
<mat-icon>person_search</mat-icon>
|
|
||||||
<span> This is an <strong>existing customer</strong>. A new client is not created for the same person. </span>
|
|
||||||
</div>
|
|
||||||
<div class="identity-check__card">
|
|
||||||
<div class="identity-check__card-head">
|
|
||||||
<strong>{{ m.displayName }}</strong>
|
|
||||||
<span class="identity-check__chips">
|
|
||||||
@for (by of m.matchedBy; track by) {
|
|
||||||
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<dl class="identity-check__facts">
|
|
||||||
<div>
|
|
||||||
<dt>CIF</dt>
|
|
||||||
<dd>{{ m.cifNumber }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Home branch</dt>
|
|
||||||
<dd>{{ m.officeName }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Type</dt>
|
|
||||||
<dd>{{ legalFormLabel(m) }}</dd>
|
|
||||||
</div>
|
|
||||||
@if (m.panMasked) {
|
|
||||||
<div>
|
|
||||||
<dt>PAN</dt>
|
|
||||||
<dd>{{ m.panMasked }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (m.aadhaarMasked) {
|
|
||||||
<div>
|
|
||||||
<dt>Aadhaar</dt>
|
|
||||||
<dd>{{ m.aadhaarMasked }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (m.gstinMasked) {
|
|
||||||
<div>
|
|
||||||
<dt>GSTIN</dt>
|
|
||||||
<dd>{{ m.gstinMasked }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
@if (m.visibleToCaller) {
|
|
||||||
<button
|
|
||||||
mat-flat-button
|
|
||||||
color="primary"
|
|
||||||
type="button"
|
|
||||||
class="identity-check__action"
|
|
||||||
[routerLink]="['/clients', m.clientId, 'general']"
|
|
||||||
>
|
|
||||||
Open this customer
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<p class="identity-check__note">
|
|
||||||
This customer is registered at <strong>{{ m.officeName }}</strong
|
|
||||||
>. They stay a single customer (one CIF). Open their page, then choose which account to open for them at
|
|
||||||
this branch. They are listed under this branch once an account is opened here.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
mat-flat-button
|
|
||||||
color="primary"
|
|
||||||
type="button"
|
|
||||||
class="identity-check__action"
|
|
||||||
[disabled]="linking"
|
|
||||||
(click)="openAccountHere(m)"
|
|
||||||
>
|
|
||||||
{{ linking ? 'Please wait…' : 'Open account for this customer' }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
-157
@@ -1,157 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-check {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
background: #f9fafb;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
&--blocking {
|
|
||||||
border-color: #fecaca;
|
|
||||||
background: #fffafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
flex: none;
|
|
||||||
color: var(--md-sys-color-primary, #1074b9);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__hint {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PAN and GSTIN read in capitals even before the value is rewritten.
|
|
||||||
&__upper {
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__fields {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__field {
|
|
||||||
flex: 1 1 220px;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__status {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
&--info {
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1e40af;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--ok {
|
|
||||||
background: #ecfdf5;
|
|
||||||
color: #047857;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--warn {
|
|
||||||
background: #fffbeb;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--error {
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__card {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__card-head {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 12px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__meta {
|
|
||||||
margin-left: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__chips {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__chip {
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #991b1b;
|
|
||||||
background: #fee2e2;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__facts {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
||||||
gap: 8px 16px;
|
|
||||||
margin: 10px 0 0;
|
|
||||||
|
|
||||||
dt {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__action {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__note {
|
|
||||||
margin: 12px 0 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-308
@@ -1,308 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
EventEmitter,
|
|
||||||
Input,
|
|
||||||
OnChanges,
|
|
||||||
OnDestroy,
|
|
||||||
OnInit,
|
|
||||||
Output,
|
|
||||||
SimpleChanges,
|
|
||||||
inject
|
|
||||||
} from '@angular/core';
|
|
||||||
import {
|
|
||||||
AbstractControl,
|
|
||||||
FormGroupDirective,
|
|
||||||
NgForm,
|
|
||||||
UntypedFormBuilder,
|
|
||||||
UntypedFormGroup,
|
|
||||||
Validators
|
|
||||||
} from '@angular/forms';
|
|
||||||
import { ErrorStateMatcher } from '@angular/material/core';
|
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { MatIcon } from '@angular/material/icon';
|
|
||||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
|
||||||
import { Observable, Subject, merge, of } from 'rxjs';
|
|
||||||
import { catchError, debounceTime, map, switchMap, takeUntil, tap } from 'rxjs/operators';
|
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
||||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
|
||||||
import { RestrictInputDirective } from '../../../directives/restrict-input.directive';
|
|
||||||
import { ClientsService } from '../../clients.service';
|
|
||||||
import { ClientIdentityLookupResult, ClientIdentityMatch } from '../../models/client-identity.model';
|
|
||||||
import { aadhaarHash } from '../../utils/aadhaar-hash.util';
|
|
||||||
|
|
||||||
export type ClientIdentityStatus = 'idle' | 'checking' | 'not-found' | 'matched' | 'conflict' | 'error';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the parent needs from the identity check: whether creation must stop, and the identifiers the officer entered
|
|
||||||
* so the KYC step can be filled in without typing them again.
|
|
||||||
*/
|
|
||||||
export interface ClientIdentityState {
|
|
||||||
status: ClientIdentityStatus;
|
|
||||||
/** Individual: the person's PAN. Entity: the company's PAN. */
|
|
||||||
pan: string | null;
|
|
||||||
/** The 12 digits, held in memory only to pre-fill the KYC step. Never sent: only its hash is. */
|
|
||||||
aadhaar: string | null;
|
|
||||||
gstin: string | null;
|
|
||||||
/** True when the customer already exists (or the identifiers disagree), so a new client must not be created. */
|
|
||||||
blocking: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a field's error as soon as something has been typed in it, not only after the field is left: a PAN that is
|
|
||||||
* wrong should say so while it is being typed, and the form should not look fine in the meantime.
|
|
||||||
*/
|
|
||||||
class ShowErrorWhenDirty implements ErrorStateMatcher {
|
|
||||||
isErrorState(control: AbstractControl | null, _form: FormGroupDirective | NgForm | null): boolean {
|
|
||||||
return !!control && control.invalid && (control.dirty || control.touched);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAN_PATTERN = /^[A-Za-z]{5}\d{4}[A-Za-z]$/;
|
|
||||||
const AADHAAR_PATTERN = /^\d{12}$/;
|
|
||||||
const GSTIN_PATTERN = /^\d{2}[A-Za-z]{5}\d{4}[A-Za-z][1-9A-Za-z]Z[0-9A-Za-z]$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Is this person already a customer of the bank?" — asked at the top of the General step, before anything is saved.
|
|
||||||
*
|
|
||||||
* Any ONE of PAN / Aadhaar (individual) or PAN / GSTIN (entity) matching an existing customer, in any branch, means
|
|
||||||
* the same customer, so a second client must not be created. When nothing matches, what was entered is carried to the
|
|
||||||
* KYC step so it does not have to be typed again.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-client-identity-check',
|
|
||||||
templateUrl: './client-identity-check.component.html',
|
|
||||||
styleUrls: ['./client-identity-check.component.scss'],
|
|
||||||
imports: [
|
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
|
||||||
MatIcon,
|
|
||||||
MatProgressSpinner,
|
|
||||||
RestrictInputDirective
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ClientIdentityCheckComponent implements OnInit, OnChanges, OnDestroy {
|
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
|
||||||
private clientsService = inject(ClientsService);
|
|
||||||
private router = inject(Router);
|
|
||||||
private authenticationService = inject(AuthenticationService);
|
|
||||||
|
|
||||||
/** 1 = individual, 2 = entity (non-individual). */
|
|
||||||
@Input() legalFormType = 1;
|
|
||||||
@Output() identityChange = new EventEmitter<ClientIdentityState>();
|
|
||||||
|
|
||||||
form: UntypedFormGroup;
|
|
||||||
readonly errorMatcher = new ShowErrorWhenDirty();
|
|
||||||
status: ClientIdentityStatus = 'idle';
|
|
||||||
matches: ClientIdentityMatch[] = [];
|
|
||||||
/** True while the customer is being introduced to this branch. */
|
|
||||||
linking = false;
|
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
|
||||||
private recheck$ = new Subject<void>();
|
|
||||||
|
|
||||||
get isEntity(): boolean {
|
|
||||||
return this.legalFormType === 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The first (and normally only) existing customer found. */
|
|
||||||
get match(): ClientIdentityMatch | null {
|
|
||||||
return this.matches[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True while a field holds something that is not a valid PAN / Aadhaar / GSTIN. Such a value is never looked up or
|
|
||||||
* carried to the KYC step, so the parent must not let the officer carry on past it as if the check had passed.
|
|
||||||
*/
|
|
||||||
get hasInvalidInput(): boolean {
|
|
||||||
return !!this.form?.invalid;
|
|
||||||
}
|
|
||||||
|
|
||||||
markAllAsTouched(): void {
|
|
||||||
this.form?.markAllAsTouched();
|
|
||||||
}
|
|
||||||
|
|
||||||
get state(): ClientIdentityState {
|
|
||||||
const v = this.normalized();
|
|
||||||
return {
|
|
||||||
status: this.status,
|
|
||||||
pan: v.pan,
|
|
||||||
aadhaar: v.aadhaar,
|
|
||||||
gstin: v.gstin,
|
|
||||||
blocking: this.status === 'matched' || this.status === 'conflict'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.form = this.formBuilder.group({
|
|
||||||
pan: [
|
|
||||||
'',
|
|
||||||
Validators.pattern(PAN_PATTERN)
|
|
||||||
],
|
|
||||||
aadhaar: [
|
|
||||||
'',
|
|
||||||
Validators.pattern(AADHAAR_PATTERN)
|
|
||||||
],
|
|
||||||
gstin: [
|
|
||||||
'',
|
|
||||||
Validators.pattern(GSTIN_PATTERN)
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// A pause in typing (or a manual re-check) runs one lookup; typing again cancels the one in flight.
|
|
||||||
merge(this.form.valueChanges, this.recheck$)
|
|
||||||
.pipe(
|
|
||||||
debounceTime(400),
|
|
||||||
tap(() => this.startChecking()),
|
|
||||||
switchMap(() => this.lookup()),
|
|
||||||
takeUntil(this.destroy$)
|
|
||||||
)
|
|
||||||
.subscribe((result) => this.applyResult(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
|
||||||
// Switching individual <-> entity swaps which identifiers apply; what was typed for the other kind is stale.
|
|
||||||
if (changes['legalFormType'] && !changes['legalFormType'].firstChange) {
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.destroy$.next();
|
|
||||||
this.destroy$.complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clears the fields and the result, e.g. when a new client creation session starts. */
|
|
||||||
reset(): void {
|
|
||||||
this.form?.reset({ pan: '', aadhaar: '', gstin: '' }, { emitEvent: false });
|
|
||||||
this.status = 'idle';
|
|
||||||
this.matches = [];
|
|
||||||
this.identityChange.emit(this.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
recheck(): void {
|
|
||||||
this.recheck$.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PAN and GSTIN are always written in capitals, so what is typed is capitalised as it is typed. The caret is put
|
|
||||||
* back where it was, otherwise editing the middle of a PAN would throw it to the end. The shared restrict-input
|
|
||||||
* directive only capitalises when the field is left, which let lower-case show while typing.
|
|
||||||
*/
|
|
||||||
capitalise(controlName: 'pan' | 'gstin', event: Event): void {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const upper = input.value.toUpperCase();
|
|
||||||
if (upper !== input.value) {
|
|
||||||
const { selectionStart, selectionEnd } = input;
|
|
||||||
input.value = upper;
|
|
||||||
input.setSelectionRange(selectionStart, selectionEnd);
|
|
||||||
}
|
|
||||||
this.form.get(controlName)?.setValue(upper, { emitEvent: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Identifiers that are complete and well formed; anything half-typed is ignored rather than looked up. */
|
|
||||||
private normalized(): { pan: string | null; aadhaar: string | null; gstin: string | null } {
|
|
||||||
const raw = this.form?.getRawValue() ?? { pan: '', aadhaar: '', gstin: '' };
|
|
||||||
const pan = String(raw.pan ?? '')
|
|
||||||
.replace(/\s+/g, '')
|
|
||||||
.toUpperCase();
|
|
||||||
const aadhaar = String(raw.aadhaar ?? '').replace(/[\s-]/g, '');
|
|
||||||
const gstin = String(raw.gstin ?? '')
|
|
||||||
.replace(/\s+/g, '')
|
|
||||||
.toUpperCase();
|
|
||||||
return {
|
|
||||||
pan: PAN_PATTERN.test(pan) ? pan : null,
|
|
||||||
aadhaar: !this.isEntity && AADHAAR_PATTERN.test(aadhaar) ? aadhaar : null,
|
|
||||||
gstin: this.isEntity && GSTIN_PATTERN.test(gstin) ? gstin : null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private startChecking(): void {
|
|
||||||
const v = this.normalized();
|
|
||||||
if (!v.pan && !v.aadhaar && !v.gstin) {
|
|
||||||
this.status = 'idle';
|
|
||||||
this.matches = [];
|
|
||||||
this.identityChange.emit(this.state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.status = 'checking';
|
|
||||||
this.matches = [];
|
|
||||||
this.identityChange.emit(this.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
private lookup(): Observable<ClientIdentityLookupResult | 'error' | null> {
|
|
||||||
const v = this.normalized();
|
|
||||||
if (!v.pan && !v.aadhaar && !v.gstin) {
|
|
||||||
return of(null);
|
|
||||||
}
|
|
||||||
return this.clientsService
|
|
||||||
.lookupClientIdentity({ panNumber: v.pan, aadhaarHash: aadhaarHash(v.aadhaar), gstinNumber: v.gstin })
|
|
||||||
.pipe(
|
|
||||||
map((result): ClientIdentityLookupResult | 'error' => result),
|
|
||||||
catchError(() => of('error' as const))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyResult(result: ClientIdentityLookupResult | 'error' | null): void {
|
|
||||||
if (result === null) {
|
|
||||||
// Nothing complete to look up; startChecking() already reset to idle.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result === 'error') {
|
|
||||||
// Not blocking: the server checks again when the KYC is saved, so a failed lookup must not stop onboarding.
|
|
||||||
this.status = 'error';
|
|
||||||
this.matches = [];
|
|
||||||
} else if (result.conflict) {
|
|
||||||
this.status = 'conflict';
|
|
||||||
this.matches = result.matches;
|
|
||||||
} else if (result.matched) {
|
|
||||||
this.status = 'matched';
|
|
||||||
this.matches = result.matches;
|
|
||||||
} else {
|
|
||||||
this.status = 'not-found';
|
|
||||||
this.matches = [];
|
|
||||||
}
|
|
||||||
this.identityChange.emit(this.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts opening an account at THIS branch for a customer registered at another one. The customer stays at the home
|
|
||||||
* branch (one CIF). This branch is introduced to them, which only lets it open their page; they are listed under
|
|
||||||
* this branch once an account is actually opened here. An account opened for a customer of another branch is held
|
|
||||||
* at the branch that opens it.
|
|
||||||
*/
|
|
||||||
openAccountHere(match: ClientIdentityMatch): void {
|
|
||||||
const officeId = this.authenticationService.getCredentials()?.officeId;
|
|
||||||
if (this.linking || officeId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.linking = true;
|
|
||||||
this.clientsService.linkClientToMyBranch(match.clientId).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.linking = false;
|
|
||||||
this.router.navigate([
|
|
||||||
'/clients',
|
|
||||||
match.clientId,
|
|
||||||
'general'
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
// The failure is shown by the global error handler.
|
|
||||||
error: () => (this.linking = false)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legal form of the existing customer, for the card. */
|
|
||||||
legalFormLabel(match: ClientIdentityMatch): string {
|
|
||||||
return match.legalFormEnum === 2 ? 'Non-Individual' : 'Individual';
|
|
||||||
}
|
|
||||||
|
|
||||||
matchedByLabel(by: string): string {
|
|
||||||
return by === 'AADHAAR' ? 'Aadhaar' : by;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,8 +43,6 @@ import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
|||||||
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||||
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
|
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
|
||||||
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||||
import { aadhaarHash } from '../../utils/aadhaar-hash.util';
|
|
||||||
import { ClientIdentityState } from '../client-identity-check/client-identity-check.component';
|
|
||||||
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||||
import {
|
import {
|
||||||
PanDeclarationDialogComponent,
|
PanDeclarationDialogComponent,
|
||||||
@@ -110,32 +108,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
@Input() verificationDateOfBirth: Date | null = null;
|
@Input() verificationDateOfBirth: Date | null = null;
|
||||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||||
@Input() readOnly = false;
|
@Input() readOnly = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* The identifiers the officer already typed into the existing-customer check on the General step. They are filled
|
|
||||||
* in here so they are not typed twice, but only into fields that are still empty: whatever was entered on this
|
|
||||||
* step wins. Ignored while the check says the customer already exists, since no client is created then.
|
|
||||||
*/
|
|
||||||
@Input() set identityPrefill(identity: ClientIdentityState | null) {
|
|
||||||
if (!identity || identity.blocking) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const isEmpty = (name: string) => !String(this.kycForm?.get(name)?.value ?? '').trim();
|
|
||||||
const patch: Record<string, unknown> = {};
|
|
||||||
if (this.isNonIndividual) {
|
|
||||||
if (identity.gstin && isEmpty('gstinNumber')) patch['gstinNumber'] = identity.gstin;
|
|
||||||
if (identity.pan && isEmpty('enterprisePan')) patch['enterprisePan'] = identity.pan;
|
|
||||||
} else {
|
|
||||||
if (identity.pan && isEmpty('panNumber')) {
|
|
||||||
patch['panNumber'] = identity.pan;
|
|
||||||
patch['hasPan'] = true;
|
|
||||||
}
|
|
||||||
if (identity.aadhaar && isEmpty('aadhaarNumber')) patch['aadhaarNumber'] = identity.aadhaar;
|
|
||||||
}
|
|
||||||
if (Object.keys(patch).length) {
|
|
||||||
this.kycForm.patchValue(patch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Input() calculatedRisk: {
|
@Input() calculatedRisk: {
|
||||||
category: number;
|
category: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
@@ -1784,8 +1756,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycType: this.kycTypeValue,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.currentRiskCategory,
|
riskCategory: this.currentRiskCategory,
|
||||||
panNumber: pan,
|
panNumber: pan,
|
||||||
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000',
|
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
|
||||||
...this.aadhaarHashField(aadhaarRaw)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
|
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
|
||||||
@@ -2387,8 +2358,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
kycType: this.kycTypeValue,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.currentRiskCategory,
|
riskCategory: this.currentRiskCategory,
|
||||||
aadhaarNumberMasked: aadhaarMasked,
|
aadhaarNumberMasked: aadhaarMasked
|
||||||
...this.aadhaarHashField(aadhaar)
|
|
||||||
};
|
};
|
||||||
const panVal = this.kycForm.value.panNumber;
|
const panVal = this.kycForm.value.panNumber;
|
||||||
if (panVal) {
|
if (panVal) {
|
||||||
@@ -2709,15 +2679,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `{ aadhaarHash }` for a full 12-digit Aadhaar, `{}` otherwise. The number itself is never sent: KYC calls are
|
|
||||||
* recorded server-side, so only its SHA-256 goes.
|
|
||||||
*/
|
|
||||||
private aadhaarHashField(aadhaar: string | null | undefined): { aadhaarHash?: string } {
|
|
||||||
const hash = aadhaarHash(aadhaar);
|
|
||||||
return hash ? { aadhaarHash: hash } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
get kycData(): any {
|
get kycData(): any {
|
||||||
const formVal = this.kycForm.getRawValue();
|
const formVal = this.kycForm.getRawValue();
|
||||||
const aadhaarRaw = formVal.aadhaarNumber || '';
|
const aadhaarRaw = formVal.aadhaarNumber || '';
|
||||||
@@ -2746,9 +2707,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
// For non-individual: Company PAN is used as panNumber for verification
|
// For non-individual: Company PAN is used as panNumber for verification
|
||||||
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
||||||
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
||||||
// The fingerprint of the full number, so the server can tell two Aadhaar numbers apart (the masked form keeps
|
|
||||||
// only the last four digits). Absent when the field holds a masked value reloaded from the server.
|
|
||||||
...(this.isNonIndividual ? {} : this.aadhaarHashField(aadhaarRaw)),
|
|
||||||
// Only ever sent when this screen actually holds a value. The seeding status is derived by
|
// Only ever sent when this screen actually holds a value. The seeding status is derived by
|
||||||
// the provider during PAN verification and written straight to the record, so sending an
|
// the provider during PAN verification and written straight to the record, so sending an
|
||||||
// empty one back would blank a field the server filled in — which reads as an officer
|
// empty one back would blank a field the server filled in — which reads as an officer
|
||||||
|
|||||||
@@ -356,15 +356,6 @@
|
|||||||
<button mat-menu-item *mifosxHasPermission="'CREATE_CURRENTACCOUNT'" [routerLink]="['current-accounts', 'create']">
|
<button mat-menu-item *mifosxHasPermission="'CREATE_CURRENTACCOUNT'" [routerLink]="['current-accounts', 'create']">
|
||||||
{{ 'labels.buttons.New Current Account' | translate }}
|
{{ 'labels.buttons.New Current Account' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<!-- A Cash Credit account is a current account on a product that allows an overdraft against a sanctioned limit. -->
|
|
||||||
<button
|
|
||||||
mat-menu-item
|
|
||||||
*mifosxHasPermission="'CREATE_CURRENTACCOUNT'"
|
|
||||||
[routerLink]="['current-accounts', 'create']"
|
|
||||||
[queryParams]="{ cc: 1 }"
|
|
||||||
>
|
|
||||||
New CC Account
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|
||||||
<mat-menu #Actions="matMenu">
|
<mat-menu #Actions="matMenu">
|
||||||
|
|||||||
@@ -90,21 +90,6 @@
|
|||||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Last Active">
|
<ng-container matColumnDef="Last Active">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||||
@@ -218,21 +203,6 @@
|
|||||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Closed Date">
|
<ng-container matColumnDef="Closed Date">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
||||||
@@ -292,21 +262,6 @@
|
|||||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Last Active">
|
<ng-container matColumnDef="Last Active">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||||
@@ -497,21 +452,6 @@
|
|||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fixed Deposit Product' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fixed Deposit Product' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.productName }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.productName }}</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Last Active">
|
<ng-container matColumnDef="Last Active">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||||
@@ -611,21 +551,6 @@
|
|||||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Closed Date">
|
<ng-container matColumnDef="Closed Date">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
||||||
@@ -686,27 +611,10 @@
|
|||||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Status">
|
<ng-container matColumnDef="Status">
|
||||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||||
<td mat-cell *matCellDef="let element">
|
<td mat-cell *matCellDef="let element">
|
||||||
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{
|
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
|
||||||
rdStatusLabel(element)
|
|
||||||
}}</span>
|
|
||||||
@if (element.timeline?.closedOnDate) {
|
@if (element.timeline?.closedOnDate) {
|
||||||
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
|
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,66 +440,3 @@ table[mat-table] {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Branch an account is held at, as a pill coloured by where the signed-in user works: green for their own branch, red for
|
|
||||||
* any other. When the customer banks at several other branches the reds run from light to dark, one shade per branch.
|
|
||||||
*/
|
|
||||||
.branch-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
.fa {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--mine {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
border-color: #4ade80;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--other-0 {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
border-color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--other-1 {
|
|
||||||
background: #fca5a5;
|
|
||||||
color: #7f1d1d;
|
|
||||||
border-color: #f87171;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--other-2 {
|
|
||||||
background: #dc2626;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #b91c1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--other-3 {
|
|
||||||
background: #991b1b;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #7f1d1d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.branch-chip__home {
|
|
||||||
padding: 0 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
/* fixed colours: the chip text turns white on the dark reds, which would vanish against this light tag */
|
|
||||||
background: rgb(255 255 255 / 85%);
|
|
||||||
color: #1f2937;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
|||||||
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
||||||
import { CurrencyPipe } from '@angular/common';
|
import { CurrencyPipe } from '@angular/common';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
|
||||||
import { ReportsService } from 'app/reports/reports.service';
|
import { ReportsService } from 'app/reports/reports.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
@@ -152,7 +151,6 @@ export class GeneralTabComponent implements OnDestroy {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private reportsService = inject(ReportsService);
|
private reportsService = inject(ReportsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private authenticationService = inject(AuthenticationService);
|
|
||||||
|
|
||||||
/** Open Loan Accounts Columns */
|
/** Open Loan Accounts Columns */
|
||||||
openLoansColumns: string[] = [
|
openLoansColumns: string[] = [
|
||||||
@@ -179,7 +177,6 @@ export class GeneralTabComponent implements OnDestroy {
|
|||||||
openSavingsColumns: string[] = [
|
openSavingsColumns: string[] = [
|
||||||
'Account No',
|
'Account No',
|
||||||
'Saving Account',
|
'Saving Account',
|
||||||
'Branch',
|
|
||||||
'Last Active',
|
'Last Active',
|
||||||
'Balance',
|
'Balance',
|
||||||
'Actions'
|
'Actions'
|
||||||
@@ -188,14 +185,12 @@ export class GeneralTabComponent implements OnDestroy {
|
|||||||
closedSavingsColumns: string[] = [
|
closedSavingsColumns: string[] = [
|
||||||
'Account No',
|
'Account No',
|
||||||
'Saving Account',
|
'Saving Account',
|
||||||
'Branch',
|
|
||||||
'Closed Date'
|
'Closed Date'
|
||||||
];
|
];
|
||||||
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
|
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
|
||||||
recurringColumns: string[] = [
|
recurringColumns: string[] = [
|
||||||
'Account No',
|
'Account No',
|
||||||
'Saving Account',
|
'Saving Account',
|
||||||
'Branch',
|
|
||||||
'Status',
|
'Status',
|
||||||
'Last Active',
|
'Last Active',
|
||||||
'Balance',
|
'Balance',
|
||||||
@@ -341,76 +336,12 @@ export class GeneralTabComponent implements OnDestroy {
|
|||||||
/** Client Id */
|
/** Client Id */
|
||||||
clientid: any;
|
clientid: any;
|
||||||
|
|
||||||
/** The customer's own (home) branch, to tell it from branches the customer banks at elsewhere. */
|
|
||||||
clientOfficeId: number | null = null;
|
|
||||||
|
|
||||||
/** How many red shades the branch chips have (general-tab.component.scss, .branch-chip--other-0 ... -3), light to dark. */
|
|
||||||
private static readonly OTHER_BRANCH_SHADES = 4;
|
|
||||||
|
|
||||||
/** The branch the signed-in user works at: accounts held here read green, every other branch red. */
|
|
||||||
private get myOfficeId(): number | null {
|
|
||||||
const officeId = this.authenticationService.getCredentials()?.officeId;
|
|
||||||
return officeId == null ? null : Number(officeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The other branches this customer banks at, lightest to darkest. Ordered by branch id so the shades stay put between
|
|
||||||
* visits; computed over every deposit account on the page so one branch has the same shade in every table.
|
|
||||||
*/
|
|
||||||
private otherBranchIds(): number[] {
|
|
||||||
const mine = this.myOfficeId;
|
|
||||||
const ids = new Set<number>();
|
|
||||||
for (const account of [
|
|
||||||
...this.savingAccounts,
|
|
||||||
...this.currentAccounts
|
|
||||||
]) {
|
|
||||||
const officeId = Number(account?.officeId);
|
|
||||||
if (Number.isFinite(officeId) && officeId !== mine) {
|
|
||||||
ids.add(officeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...ids].sort((a, b) => a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Green for an account at the signed-in user's branch; red for any other branch, a different shade for each. */
|
|
||||||
branchChipClass(account: any): string {
|
|
||||||
const officeId = Number(account?.officeId);
|
|
||||||
if (officeId === this.myOfficeId) {
|
|
||||||
return 'branch-chip branch-chip--mine';
|
|
||||||
}
|
|
||||||
const others = this.otherBranchIds();
|
|
||||||
const rank = Math.max(others.indexOf(officeId), 0);
|
|
||||||
const last = GeneralTabComponent.OTHER_BRANCH_SHADES - 1;
|
|
||||||
// one other branch gets a mid red, several are spread from the lightest to the darkest
|
|
||||||
const shade = others.length <= 1 ? 1 : Math.round((rank * last) / (others.length - 1));
|
|
||||||
return `branch-chip branch-chip--other-${shade}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
branchChipTitle(account: any): string {
|
|
||||||
const parts = [account?.officeName];
|
|
||||||
if (Number(account?.officeId) === this.myOfficeId) {
|
|
||||||
parts.push('your branch');
|
|
||||||
}
|
|
||||||
if (this.isHomeBranch(account)) {
|
|
||||||
parts.push('customer home branch');
|
|
||||||
}
|
|
||||||
return parts.join(' - ');
|
|
||||||
}
|
|
||||||
|
|
||||||
isHomeBranch(account: any): boolean {
|
|
||||||
return this.clientOfficeId != null && Number(account?.officeId) === this.clientOfficeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ActivatedRoute} route Activated Route
|
* @param {ActivatedRoute} route Activated Route
|
||||||
* @param {ClientsService} clientService Clients Service
|
* @param {ClientsService} clientService Clients Service
|
||||||
* @param {Router} router Router
|
* @param {Router} router Router
|
||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
// The customer's record is resolved on the parent route, not on this tab's own route data.
|
|
||||||
this.route.parent?.data.subscribe((data: any) => {
|
|
||||||
this.clientOfficeId = data?.clientViewData?.officeId ?? null;
|
|
||||||
});
|
|
||||||
this.route.data.subscribe(
|
this.route.data.subscribe(
|
||||||
(data: { clientAccountsData: any; clientChargesData: any; clientSummary: any; clientCollateralData: any }) => {
|
(data: { clientAccountsData: any; clientChargesData: any; clientSummary: any; clientCollateralData: any }) => {
|
||||||
this.clientAccountData = data.clientAccountsData;
|
this.clientAccountData = data.clientAccountsData;
|
||||||
|
|||||||
@@ -50,17 +50,6 @@
|
|||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
@if (isHeadOffice) {
|
|
||||||
<mat-form-field class="table-filter" appearance="outline">
|
|
||||||
<mat-select [(value)]="officeFilter" (selectionChange)="officeChanged()" placeholder="Branch">
|
|
||||||
<mat-option [value]="null">All Branches</mat-option>
|
|
||||||
@for (office of offices; track office.id) {
|
|
||||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
<mat-form-field class="table-filter" appearance="outline">
|
<mat-form-field class="table-filter" appearance="outline">
|
||||||
<mat-select [(value)]="statusFilter" (selectionChange)="applyClientFilters()" placeholder="Status">
|
<mat-select [(value)]="statusFilter" (selectionChange)="applyClientFilters()" placeholder="Status">
|
||||||
<mat-option value="">All Status</mat-option>
|
<mat-option value="">All Status</mat-option>
|
||||||
@@ -103,11 +92,6 @@
|
|||||||
row.legalForm?.id === 2 ? 'business' : 'person'
|
row.legalForm?.id === 2 ? 'business' : 'person'
|
||||||
}}</mat-icon>
|
}}</mat-icon>
|
||||||
{{ hideClientData ? maskName(row.displayName) : row.displayName }}
|
{{ hideClientData ? maskName(row.displayName) : row.displayName }}
|
||||||
@if (!isHeadOffice && row.officeId && myOfficeId !== null && row.officeId !== myOfficeId) {
|
|
||||||
<span class="row-home-chip" matTooltip="Registered at another branch; linked to this branch">
|
|
||||||
Home: {{ row.officeName }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@if (row._ckycDone) {
|
@if (row._ckycDone) {
|
||||||
<mat-icon class="row-ckyc-icon" matTooltip="CKYC Done">verified</mat-icon>
|
<mat-icon class="row-ckyc-icon" matTooltip="CKYC Done">verified</mat-icon>
|
||||||
}
|
}
|
||||||
@@ -130,12 +114,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Branch Column (Head Office only) -->
|
|
||||||
<ng-container matColumnDef="officeName">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Branch</th>
|
|
||||||
<td mat-cell *matCellDef="let row">{{ row.officeName }}</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Status Column -->
|
<!-- Status Column -->
|
||||||
<ng-container matColumnDef="status">
|
<ng-container matColumnDef="status">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||||
|
|||||||
@@ -7,15 +7,3 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@use '../shared/styles/list-layout' as *;
|
@use '../shared/styles/list-layout' as *;
|
||||||
|
|
||||||
// A customer registered at another branch that has been linked to this one.
|
|
||||||
.row-home-chip {
|
|
||||||
margin-left: 8px;
|
|
||||||
padding: 1px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #92400e;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 999px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ import { MatIconButton, MatButton } from '@angular/material/button';
|
|||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { AuthenticationService } from '../core/authentication/authentication.service';
|
|
||||||
import { OrganizationService } from '../organization/organization.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-clients',
|
selector: 'mifosx-clients',
|
||||||
@@ -73,15 +71,6 @@ import { OrganizationService } from '../organization/organization.service';
|
|||||||
})
|
})
|
||||||
export class ClientsComponent implements OnInit {
|
export class ClientsComponent implements OnInit {
|
||||||
private clientService = inject(ClientsService);
|
private clientService = inject(ClientsService);
|
||||||
private authenticationService = inject(AuthenticationService);
|
|
||||||
private organizationService = inject(OrganizationService);
|
|
||||||
|
|
||||||
/** Head Office sees every branch's clients, so it gets a Branch filter. A branch user only ever sees its own. */
|
|
||||||
isHeadOffice = this.authenticationService.isHeadOfficeUser();
|
|
||||||
offices: { id: number; name: string }[] = [];
|
|
||||||
officeFilter: number | null = null;
|
|
||||||
/** The caller's own branch, to tell a customer of another branch (linked here) from this branch's own. */
|
|
||||||
myOfficeId = this.authenticationService.getCredentials()?.officeId ?? null;
|
|
||||||
|
|
||||||
/** Returns true if client data masking is enabled */
|
/** Returns true if client data masking is enabled */
|
||||||
get hideClientData(): boolean {
|
get hideClientData(): boolean {
|
||||||
@@ -101,7 +90,6 @@ export class ClientsComponent implements OnInit {
|
|||||||
'displayName',
|
'displayName',
|
||||||
'cifNumber',
|
'cifNumber',
|
||||||
'legalForm',
|
'legalForm',
|
||||||
'officeName',
|
|
||||||
'status',
|
'status',
|
||||||
'profileCompletion',
|
'profileCompletion',
|
||||||
'actions'
|
'actions'
|
||||||
@@ -131,13 +119,6 @@ export class ClientsComponent implements OnInit {
|
|||||||
@ViewChild(MatSort) sort: MatSort;
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (!this.isHeadOffice) {
|
|
||||||
this.displayedColumns = this.displayedColumns.filter((c) => c !== 'officeName');
|
|
||||||
} else {
|
|
||||||
this.organizationService.getOffices().subscribe((offices: any[]) => {
|
|
||||||
this.offices = (offices || []).map((o) => ({ id: o.id, name: o.name }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (environment.preloadClients) {
|
if (environment.preloadClients) {
|
||||||
this.getClients();
|
this.getClients();
|
||||||
}
|
}
|
||||||
@@ -163,8 +144,7 @@ export class ClientsComponent implements OnInit {
|
|||||||
this.sortDirection,
|
this.sortDirection,
|
||||||
false,
|
false,
|
||||||
'',
|
'',
|
||||||
true,
|
true
|
||||||
this.isHeadOffice ? this.officeFilter : null
|
|
||||||
)
|
)
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
@@ -253,12 +233,6 @@ export class ClientsComponent implements OnInit {
|
|||||||
this.dataSource.data = filtered;
|
this.dataSource.data = filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Branch filter is applied by the server so paging and the record count stay correct. */
|
|
||||||
officeChanged() {
|
|
||||||
this.resetPaginator();
|
|
||||||
this.getClients();
|
|
||||||
}
|
|
||||||
|
|
||||||
pageChanged(event: PageEvent) {
|
pageChanged(event: PageEvent) {
|
||||||
this.pageSize = event.pageSize;
|
this.pageSize = event.pageSize;
|
||||||
this.currentPage = event.pageIndex;
|
this.currentPage = event.pageIndex;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { map, catchError } from 'rxjs/operators';
|
|||||||
|
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||||
import { ClientIdentityLookupResult } from './models/client-identity.model';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clients service.
|
* Clients service.
|
||||||
@@ -743,14 +742,9 @@ export class ClientsService {
|
|||||||
sortDirection: string = '',
|
sortDirection: string = '',
|
||||||
activeOnly: boolean = false,
|
activeOnly: boolean = false,
|
||||||
legalFormType: string = '',
|
legalFormType: string = '',
|
||||||
silent: boolean = false,
|
silent: boolean = false
|
||||||
officeId: number | null = null
|
|
||||||
) {
|
) {
|
||||||
const innerRequest: any = { text };
|
const innerRequest: any = { text };
|
||||||
// Branch filter (Head Office list). The server still limits results to the caller's office hierarchy.
|
|
||||||
if (officeId != null) {
|
|
||||||
innerRequest.officeId = officeId;
|
|
||||||
}
|
|
||||||
if (activeOnly) {
|
if (activeOnly) {
|
||||||
innerRequest.activeOnly = true;
|
innerRequest.activeOnly = true;
|
||||||
}
|
}
|
||||||
@@ -846,11 +840,6 @@ export class ClientsService {
|
|||||||
return this.http.post('/kyc/risk-rules', rule);
|
return this.http.post('/kyc/risk-rules', rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ends the trial of a newly added rule; it stays in the catalog as a normal rule. */
|
|
||||||
markClientRiskRuleNormal(ruleId: number): Observable<any> {
|
|
||||||
return this.http.post(`/kyc/risk-rules/${ruleId}/mark-normal`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch code values by code ID */
|
/** Fetch code values by code ID */
|
||||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||||
@@ -879,39 +868,6 @@ export class ClientsService {
|
|||||||
return this.http.post(`/clients/${clientId}/kyc`, kycData);
|
return this.http.post(`/clients/${clientId}/kyc`, kycData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "Is this person already a customer of the bank?" — across every branch, by PAN, Aadhaar fingerprint or GSTIN.
|
|
||||||
* Any ONE of them matching means the same customer. `contactConflicts` reports a phone/email already used by another
|
|
||||||
* client, which is never the same customer. Only the Aadhaar's SHA-256 is sent, never the number.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Introduces a customer of another branch to the caller's branch, so the branch can see them and open an account
|
|
||||||
* for them there. The customer stays at the home branch. Safe to repeat.
|
|
||||||
*/
|
|
||||||
linkClientToMyBranch(
|
|
||||||
clientId: number
|
|
||||||
): Observable<{ clientId: number; linked: boolean; officeId: number; officeName: string }> {
|
|
||||||
return this.http.post<{ clientId: number; linked: boolean; officeId: number; officeName: string }>(
|
|
||||||
'/clientidentity/link',
|
|
||||||
{
|
|
||||||
clientId
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
lookupClientIdentity(identity: {
|
|
||||||
panNumber?: string | null;
|
|
||||||
aadhaarHash?: string | null;
|
|
||||||
gstinNumber?: string | null;
|
|
||||||
mobileNo?: string | null;
|
|
||||||
emailAddress?: string | null;
|
|
||||||
excludeClientId?: number | null;
|
|
||||||
}): Observable<ClientIdentityLookupResult> {
|
|
||||||
return this.http.post<ClientIdentityLookupResult>('/clientidentity/lookup', identity, {
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen a client name against the UN Security Council consolidated sanctions list.
|
* Screen a client name against the UN Security Council consolidated sanctions list.
|
||||||
* @param name The name to screen
|
* @param name The name to screen
|
||||||
@@ -1117,91 +1073,9 @@ export class ClientsService {
|
|||||||
*/
|
*/
|
||||||
saveKycContactLog(
|
saveKycContactLog(
|
||||||
clientId: string | number,
|
clientId: string | number,
|
||||||
data: { channel: string; notes: string; officerName?: string; noticeType?: string },
|
data: { channel: string; notes: string; officerName?: string }
|
||||||
silent = false
|
|
||||||
): Observable<any> {
|
): Observable<any> {
|
||||||
return this.http.post(
|
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
|
||||||
`/kyc/exceptions/${clientId}/contact-log`,
|
|
||||||
data,
|
|
||||||
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The subject and text a customer would receive for the stage they are at today.
|
|
||||||
* `type` is KYC or DORMANCY; the body comes back empty when they are at no such stage.
|
|
||||||
*/
|
|
||||||
previewKycNotice(clientId: string | number, type: string): Observable<{ subject?: string; body?: string }> {
|
|
||||||
return this.http.get<{ subject?: string; body?: string }>(
|
|
||||||
`/kyc/exceptions/${clientId}/contact-log/preview?type=${type}`,
|
|
||||||
{
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Queues notices to be sent in the background by the queue job. */
|
|
||||||
enqueueKycNotices(body: {
|
|
||||||
items: { clientId: number; noticeType: string; channel: string; accountNo?: string; stageLabel?: string }[];
|
|
||||||
notes?: string;
|
|
||||||
officerName?: string;
|
|
||||||
sendAt?: string;
|
|
||||||
}): Observable<{ queued: number; skipped: number }> {
|
|
||||||
return this.http.post<{ queued: number; skipped: number }>('/kyc/notice-queue', body);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Queued notices, newest first; status narrows to PENDING, SENT, FAILED or CANCELLED. */
|
|
||||||
getKycNoticeQueue(status = ''): Observable<any[]> {
|
|
||||||
return this.http.get<any[]>(`/kyc/notice-queue${status ? `?status=${status}` : ''}`, {
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
actOnKycNotice(id: number, command: 'cancel' | 'retry'): Observable<any> {
|
|
||||||
return this.http.post(`/kyc/notice-queue/${id}?command=${command}`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Notices per day (customers, notices, outcomes), newest day first. */
|
|
||||||
getKycContactAuditDays(
|
|
||||||
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string } = {}
|
|
||||||
): Observable<any[]> {
|
|
||||||
return this.http.get<any[]>('/kyc/contact-audit/days', {
|
|
||||||
params: this.auditParams(filter),
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Customer notices on record, newest first, a page at a time. */
|
|
||||||
getKycContactAudit(
|
|
||||||
filter: {
|
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
channel?: string;
|
|
||||||
status?: string;
|
|
||||||
search?: string;
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
} = {}
|
|
||||||
): Observable<{ total: number; rows: any[] }> {
|
|
||||||
return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit', {
|
|
||||||
params: this.auditParams(filter),
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private auditParams(filter: Record<string, string | number | undefined>): HttpParams {
|
|
||||||
let params = new HttpParams();
|
|
||||||
Object.entries(filter).forEach(
|
|
||||||
([
|
|
||||||
k,
|
|
||||||
v
|
|
||||||
]) => {
|
|
||||||
if (v !== undefined && v !== null && v !== '') {
|
|
||||||
params = params.set(k, String(v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return params;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getKycContactLog(clientId: string | number): Observable<any[]> {
|
getKycContactLog(clientId: string | number): Observable<any[]> {
|
||||||
@@ -1218,14 +1092,6 @@ export class ClientsService {
|
|||||||
return this.http.get<any[]>('/kyc/exceptions/lapsed');
|
return this.http.get<any[]>('/kyc/exceptions/lapsed');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clients whose re-KYC falls due within the next `days` days (default 30), soonest first. */
|
|
||||||
getExpiringKyc(days = 30): Observable<any[]> {
|
|
||||||
// The caller reports a failure in its own banner, so the global popup is suppressed.
|
|
||||||
return this.http.get<any[]>(`/kyc/exceptions/expiring?days=${days}`, {
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Savings accounts the dormancy job has marked.
|
* Savings accounts the dormancy job has marked.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -38,12 +38,6 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!createdClientId) {
|
|
||||||
<mifosx-client-identity-check
|
|
||||||
[legalFormType]="legalFormType"
|
|
||||||
(identityChange)="onIdentityChange($event)"
|
|
||||||
></mifosx-client-identity-check>
|
|
||||||
}
|
|
||||||
<mifosx-client-general-step
|
<mifosx-client-general-step
|
||||||
[clientTemplate]="clientTemplate"
|
[clientTemplate]="clientTemplate"
|
||||||
[prefillData]="prefillData"
|
[prefillData]="prefillData"
|
||||||
@@ -85,7 +79,6 @@
|
|||||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||||
[readOnly]="isTabReadOnly(1)"
|
[readOnly]="isTabReadOnly(1)"
|
||||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||||
[identityPrefill]="identity"
|
|
||||||
></mifosx-client-kyc-step>
|
></mifosx-client-kyc-step>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ import { ClientIndiaAddressStepComponent } from '../client-stepper/client-india-
|
|||||||
import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component';
|
import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component';
|
||||||
import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component';
|
import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component';
|
||||||
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
|
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
|
||||||
import {
|
|
||||||
ClientIdentityCheckComponent,
|
|
||||||
ClientIdentityState
|
|
||||||
} from '../client-stepper/client-identity-check/client-identity-check.component';
|
|
||||||
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
|
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
|
||||||
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
|
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
|
||||||
|
|
||||||
@@ -64,7 +60,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
ClientEntityKycStepComponent,
|
ClientEntityKycStepComponent,
|
||||||
ClientDatatableStepComponent,
|
ClientDatatableStepComponent,
|
||||||
ClientKycStepComponent,
|
ClientKycStepComponent,
|
||||||
ClientIdentityCheckComponent,
|
|
||||||
ClientPreviewStepComponent,
|
ClientPreviewStepComponent,
|
||||||
ClientFamilyMembersStepComponent,
|
ClientFamilyMembersStepComponent,
|
||||||
MatProgressBarModule,
|
MatProgressBarModule,
|
||||||
@@ -84,7 +79,6 @@ export class CreateClientComponent {
|
|||||||
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
|
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
|
||||||
@ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
|
@ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
|
||||||
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
|
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
|
||||||
@ViewChild(ClientIdentityCheckComponent) identityCheck: ClientIdentityCheckComponent;
|
|
||||||
@ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
@ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
||||||
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
|
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
|
||||||
|
|
||||||
@@ -93,17 +87,6 @@ export class CreateClientComponent {
|
|||||||
selectedTabIndex = 0;
|
selectedTabIndex = 0;
|
||||||
createdClientId = '';
|
createdClientId = '';
|
||||||
savingClient = false;
|
savingClient = false;
|
||||||
/**
|
|
||||||
* Result of the existing-customer check at the top of the General step. While it says the customer already exists
|
|
||||||
* (or the identifiers disagree) a new client must not be created; otherwise the identifiers typed there are filled
|
|
||||||
* into the KYC step.
|
|
||||||
*/
|
|
||||||
identity: ClientIdentityState | null = null;
|
|
||||||
|
|
||||||
onIdentityChange(state: ClientIdentityState): void {
|
|
||||||
this.identity = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pre-fill data passed from "Create as Client" on a manual family member */
|
/** Pre-fill data passed from "Create as Client" on a manual family member */
|
||||||
prefillData: any = null;
|
prefillData: any = null;
|
||||||
|
|
||||||
@@ -155,8 +138,6 @@ export class CreateClientComponent {
|
|||||||
this.highestTabReached = 0;
|
this.highestTabReached = 0;
|
||||||
this.validationError = '';
|
this.validationError = '';
|
||||||
this.prefillData = null;
|
this.prefillData = null;
|
||||||
this.identity = null;
|
|
||||||
this.identityCheck?.reset();
|
|
||||||
this.clientGeneralStep?.resetForNewClient();
|
this.clientGeneralStep?.resetForNewClient();
|
||||||
this.clientKycStep?.resetForNewClient();
|
this.clientKycStep?.resetForNewClient();
|
||||||
this.clientIndiaAddressStep?.resetForNewClient();
|
this.clientIndiaAddressStep?.resetForNewClient();
|
||||||
@@ -231,10 +212,6 @@ export class CreateClientComponent {
|
|||||||
const idx = this.selectedTabIndex;
|
const idx = this.selectedTabIndex;
|
||||||
|
|
||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
// The customer is already on the books (any branch), or the identifiers belong to different customers.
|
|
||||||
if (!this.createdClientId && (this.identity?.blocking || this.identity?.status === 'checking')) return false;
|
|
||||||
// A PAN / Aadhaar / GSTIN typed in the wrong format is neither looked up nor carried on, so it must be fixed or cleared.
|
|
||||||
if (!this.createdClientId && this.identityCheck?.hasInvalidInput) return false;
|
|
||||||
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
||||||
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
||||||
// A number the officer rejected on the DIP result must be replaced, not carried forward.
|
// A number the officer rejected on the DIP result must be replaced, not carried forward.
|
||||||
@@ -297,17 +274,6 @@ export class CreateClientComponent {
|
|||||||
if (this.savingClient) return 'Saving — please wait…';
|
if (this.savingClient) return 'Saving — please wait…';
|
||||||
if (this.currentStepValid) return '';
|
if (this.currentStepValid) return '';
|
||||||
const idx = this.selectedTabIndex;
|
const idx = this.selectedTabIndex;
|
||||||
if (idx === 0 && !this.createdClientId && this.identityCheck?.hasInvalidInput) {
|
|
||||||
return 'Correct the PAN / Aadhaar / GSTIN format in the Existing customer check, or clear the field.';
|
|
||||||
}
|
|
||||||
if (idx === 0 && !this.createdClientId && this.identity?.status === 'checking') {
|
|
||||||
return 'Checking whether this customer already exists…';
|
|
||||||
}
|
|
||||||
if (idx === 0 && !this.createdClientId && this.identity?.blocking) {
|
|
||||||
return this.identity.status === 'conflict'
|
|
||||||
? 'The PAN / Aadhaar / GSTIN entered belong to different customers — correct them or ask a manager.'
|
|
||||||
: 'This customer already exists — a new client is not created for the same person. Open the existing customer instead.';
|
|
||||||
}
|
|
||||||
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
||||||
const disposition = this.clientGeneralStep.sanctionsDisposition;
|
const disposition = this.clientGeneralStep.sanctionsDisposition;
|
||||||
if (disposition === 'FALSE_POSITIVE') {
|
if (disposition === 'FALSE_POSITIVE') {
|
||||||
@@ -392,19 +358,6 @@ export class CreateClientComponent {
|
|||||||
onNext() {
|
onNext() {
|
||||||
// If on General tab and client not yet created — save first
|
// If on General tab and client not yet created — save first
|
||||||
if (this.selectedTabIndex === 0 && !this.createdClientId) {
|
if (this.selectedTabIndex === 0 && !this.createdClientId) {
|
||||||
// Defensive re-check: the button is disabled while this holds, but a second client for the same person is the
|
|
||||||
// one mistake this screen exists to prevent.
|
|
||||||
if (this.identityCheck?.hasInvalidInput) {
|
|
||||||
this.identityCheck.markAllAsTouched();
|
|
||||||
this.snackBar.open(this.currentStepBlockerMessage, 'Close', { duration: 6000 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.identity?.blocking) {
|
|
||||||
this.snackBar.open(this.currentStepBlockerMessage || 'This customer already exists.', 'Close', {
|
|
||||||
duration: 6000
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.clientGeneralForm?.valid) {
|
if (!this.clientGeneralForm?.valid) {
|
||||||
this.clientGeneralForm?.markAllAsTouched();
|
this.clientGeneralForm?.markAllAsTouched();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** An existing customer found by PAN / Aadhaar fingerprint / GSTIN. Identifiers are masked by the server. */
|
|
||||||
export interface ClientIdentityMatch {
|
|
||||||
clientId: number;
|
|
||||||
displayName: string;
|
|
||||||
cifNumber: string;
|
|
||||||
officeId: number;
|
|
||||||
officeName: string;
|
|
||||||
legalFormEnum: number | null;
|
|
||||||
kycStatus: number;
|
|
||||||
/** True when the caller's branch can open this client; false when it sits in another branch. */
|
|
||||||
visibleToCaller: boolean;
|
|
||||||
panMasked: string | null;
|
|
||||||
aadhaarMasked: string | null;
|
|
||||||
gstinMasked: string | null;
|
|
||||||
matchedBy: Array<'PAN' | 'AADHAAR' | 'GSTIN'>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientIdentityLookupResult {
|
|
||||||
/** Any ONE of PAN / Aadhaar / GSTIN matched an existing customer: the same person or entity. */
|
|
||||||
matched: boolean;
|
|
||||||
/** The identifiers point at different customers: bad data or fraud, not a customer to reuse. */
|
|
||||||
conflict: boolean;
|
|
||||||
matches: ClientIdentityMatch[];
|
|
||||||
/** A phone or email already used by another client. Never means "the same customer". */
|
|
||||||
contactConflicts: { mobileNo: boolean; emailAddress: boolean };
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { createHash } from 'crypto';
|
|
||||||
import { aadhaarHash, sha256Hex } from './aadhaar-hash.util';
|
|
||||||
|
|
||||||
describe('sha256Hex', () => {
|
|
||||||
it('matches the published SHA-256 test vectors', () => {
|
|
||||||
expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
|
||||||
expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
|
|
||||||
expect(sha256Hex('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')).toBe(
|
|
||||||
'248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('agrees with Node on 12-digit inputs, including ones that need a second block boundary', () => {
|
|
||||||
for (const value of [
|
|
||||||
'000000000000',
|
|
||||||
'123456789012',
|
|
||||||
'999999999999',
|
|
||||||
'0'.repeat(55),
|
|
||||||
'0'.repeat(56),
|
|
||||||
'0'.repeat(64)
|
|
||||||
]) {
|
|
||||||
expect(sha256Hex(value)).toBe(createHash('sha256').update(value).digest('hex'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('aadhaarHash', () => {
|
|
||||||
it('hashes a 12-digit number', () => {
|
|
||||||
expect(aadhaarHash('123456789012')).toBe(createHash('sha256').update('123456789012').digest('hex'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores the spaces and hyphens people type between the groups', () => {
|
|
||||||
expect(aadhaarHash('1234 5678 9012')).toBe(aadhaarHash('123456789012'));
|
|
||||||
expect(aadhaarHash('1234-5678-9012')).toBe(aadhaarHash('123456789012'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for anything that is not a full number', () => {
|
|
||||||
expect(aadhaarHash('')).toBeNull();
|
|
||||||
expect(aadhaarHash(null)).toBeNull();
|
|
||||||
expect(aadhaarHash('XXXXXXXX1234')).toBeNull();
|
|
||||||
expect(aadhaarHash('XXXXXXXX0000')).toBeNull();
|
|
||||||
expect(aadhaarHash('12345678901')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The comparable fingerprint of an Aadhaar number: SHA-256 of the 12 digits, as 64 lowercase hex characters.
|
|
||||||
*
|
|
||||||
* Only this fingerprint is sent to the server. The KYC create/update calls are recorded in an audit table and a log
|
|
||||||
* on the server, so the 12-digit number must not be in the request.
|
|
||||||
*
|
|
||||||
* A plain, unkeyed SHA-256 of 12 digits can be reversed by trying every number, so this is a lookup key and not
|
|
||||||
* protection against someone who can read the stored value.
|
|
||||||
*
|
|
||||||
* Written out in full instead of using `crypto.subtle` because that API is asynchronous and only exists in secure
|
|
||||||
* contexts, while the callers (a form-value getter, a blur handler) need the answer synchronously and the app may be
|
|
||||||
* served over plain HTTP.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const K = [
|
|
||||||
0x428a2f98,
|
|
||||||
0x71374491,
|
|
||||||
0xb5c0fbcf,
|
|
||||||
0xe9b5dba5,
|
|
||||||
0x3956c25b,
|
|
||||||
0x59f111f1,
|
|
||||||
0x923f82a4,
|
|
||||||
0xab1c5ed5,
|
|
||||||
0xd807aa98,
|
|
||||||
0x12835b01,
|
|
||||||
0x243185be,
|
|
||||||
0x550c7dc3,
|
|
||||||
0x72be5d74,
|
|
||||||
0x80deb1fe,
|
|
||||||
0x9bdc06a7,
|
|
||||||
0xc19bf174,
|
|
||||||
0xe49b69c1,
|
|
||||||
0xefbe4786,
|
|
||||||
0x0fc19dc6,
|
|
||||||
0x240ca1cc,
|
|
||||||
0x2de92c6f,
|
|
||||||
0x4a7484aa,
|
|
||||||
0x5cb0a9dc,
|
|
||||||
0x76f988da,
|
|
||||||
0x983e5152,
|
|
||||||
0xa831c66d,
|
|
||||||
0xb00327c8,
|
|
||||||
0xbf597fc7,
|
|
||||||
0xc6e00bf3,
|
|
||||||
0xd5a79147,
|
|
||||||
0x06ca6351,
|
|
||||||
0x14292967,
|
|
||||||
0x27b70a85,
|
|
||||||
0x2e1b2138,
|
|
||||||
0x4d2c6dfc,
|
|
||||||
0x53380d13,
|
|
||||||
0x650a7354,
|
|
||||||
0x766a0abb,
|
|
||||||
0x81c2c92e,
|
|
||||||
0x92722c85,
|
|
||||||
0xa2bfe8a1,
|
|
||||||
0xa81a664b,
|
|
||||||
0xc24b8b70,
|
|
||||||
0xc76c51a3,
|
|
||||||
0xd192e819,
|
|
||||||
0xd6990624,
|
|
||||||
0xf40e3585,
|
|
||||||
0x106aa070,
|
|
||||||
0x19a4c116,
|
|
||||||
0x1e376c08,
|
|
||||||
0x2748774c,
|
|
||||||
0x34b0bcb5,
|
|
||||||
0x391c0cb3,
|
|
||||||
0x4ed8aa4a,
|
|
||||||
0x5b9cca4f,
|
|
||||||
0x682e6ff3,
|
|
||||||
0x748f82ee,
|
|
||||||
0x78a5636f,
|
|
||||||
0x84c87814,
|
|
||||||
0x8cc70208,
|
|
||||||
0x90befffa,
|
|
||||||
0xa4506ceb,
|
|
||||||
0xbef9a3f7,
|
|
||||||
0xc67178f2
|
|
||||||
];
|
|
||||||
|
|
||||||
function rotr(x: number, n: number): number {
|
|
||||||
return (x >>> n) | (x << (32 - n));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SHA-256 of a string (UTF-8 encoded), as lowercase hex. */
|
|
||||||
export function sha256Hex(text: string): string {
|
|
||||||
const bytes: number[] = [];
|
|
||||||
for (const ch of unescape(encodeURIComponent(text))) {
|
|
||||||
bytes.push(ch.charCodeAt(0));
|
|
||||||
}
|
|
||||||
const bitLength = bytes.length * 8;
|
|
||||||
|
|
||||||
bytes.push(0x80);
|
|
||||||
while (bytes.length % 64 !== 56) {
|
|
||||||
bytes.push(0);
|
|
||||||
}
|
|
||||||
// 64-bit big-endian message length; inputs here are tiny so the high word is always 0.
|
|
||||||
const high = Math.floor(bitLength / 0x100000000);
|
|
||||||
const low = bitLength >>> 0;
|
|
||||||
for (const word of [
|
|
||||||
high,
|
|
||||||
low
|
|
||||||
]) {
|
|
||||||
bytes.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff);
|
|
||||||
}
|
|
||||||
|
|
||||||
const h = [
|
|
||||||
0x6a09e667,
|
|
||||||
0xbb67ae85,
|
|
||||||
0x3c6ef372,
|
|
||||||
0xa54ff53a,
|
|
||||||
0x510e527f,
|
|
||||||
0x9b05688c,
|
|
||||||
0x1f83d9ab,
|
|
||||||
0x5be0cd19
|
|
||||||
];
|
|
||||||
const w = new Array<number>(64);
|
|
||||||
|
|
||||||
for (let offset = 0; offset < bytes.length; offset += 64) {
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
const j = offset + i * 4;
|
|
||||||
w[i] = (bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3];
|
|
||||||
}
|
|
||||||
for (let i = 16; i < 64; i++) {
|
|
||||||
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
|
||||||
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
|
||||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let [
|
|
||||||
a,
|
|
||||||
b,
|
|
||||||
c,
|
|
||||||
d,
|
|
||||||
e,
|
|
||||||
f,
|
|
||||||
g,
|
|
||||||
hh
|
|
||||||
] = h;
|
|
||||||
for (let i = 0; i < 64; i++) {
|
|
||||||
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
||||||
const ch = (e & f) ^ (~e & g);
|
|
||||||
const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
|
|
||||||
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
||||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
||||||
const t2 = (S0 + maj) | 0;
|
|
||||||
hh = g;
|
|
||||||
g = f;
|
|
||||||
f = e;
|
|
||||||
e = (d + t1) | 0;
|
|
||||||
d = c;
|
|
||||||
c = b;
|
|
||||||
b = a;
|
|
||||||
a = (t1 + t2) | 0;
|
|
||||||
}
|
|
||||||
h[0] = (h[0] + a) | 0;
|
|
||||||
h[1] = (h[1] + b) | 0;
|
|
||||||
h[2] = (h[2] + c) | 0;
|
|
||||||
h[3] = (h[3] + d) | 0;
|
|
||||||
h[4] = (h[4] + e) | 0;
|
|
||||||
h[5] = (h[5] + f) | 0;
|
|
||||||
h[6] = (h[6] + g) | 0;
|
|
||||||
h[7] = (h[7] + hh) | 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.map((x) => (x >>> 0).toString(16).padStart(8, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fingerprint for a 12-digit Aadhaar, or null when the value is not one (too short, masked, a placeholder).
|
|
||||||
* Spaces and hyphens people type between the groups are ignored.
|
|
||||||
*/
|
|
||||||
export function aadhaarHash(aadhaar: string | null | undefined): string | null {
|
|
||||||
const digits = (aadhaar ?? '').replace(/[\s-]/g, '');
|
|
||||||
return /^\d{12}$/.test(digits) ? sha256Hex(digits) : null;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { toPersonNameCase, toProperNameCase } from './name-case.util';
|
import { toProperNameCase } from './name-case.util';
|
||||||
|
|
||||||
describe('toProperNameCase', () => {
|
describe('toProperNameCase', () => {
|
||||||
it('raises a lower-case first letter on every word', () => {
|
it('raises a lower-case first letter on every word', () => {
|
||||||
@@ -53,20 +53,3 @@ describe('toProperNameCase', () => {
|
|||||||
expect(toProperNameCase(undefined)).toBe('');
|
expect(toProperNameCase(undefined)).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toPersonNameCase', () => {
|
|
||||||
it('title-cases every word, lowering mixed or upper case', () => {
|
|
||||||
expect(toPersonNameCase('keshav SIngh sisodiya')).toBe('Keshav Singh Sisodiya');
|
|
||||||
expect(toPersonNameCase('KESHAV')).toBe('Keshav');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps hyphen and apostrophe name prefixes', () => {
|
|
||||||
expect(toPersonNameCase('JEAN-marie')).toBe('Jean-Marie');
|
|
||||||
expect(toPersonNameCase("D'SOUZA")).toBe("D'Souza");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an empty string for blank or non-string input', () => {
|
|
||||||
expect(toPersonNameCase(' ')).toBe('');
|
|
||||||
expect(toPersonNameCase(null)).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -62,17 +62,3 @@ export function toProperNameCase(value: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
return collapsed.split(' ').map(capitalizeWord).join(' ');
|
return collapsed.split(' ').map(capitalizeWord).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Title case for an individual's first, middle and last name: each word's first letter raised,
|
|
||||||
* the rest lowered, so 'SIngh' and 'singh' both become 'Singh'. This is what the Full Name line on
|
|
||||||
* the general step has always shown; using it for the stored value too keeps the preview and the
|
|
||||||
* saved client the same as that line. Entity names stay on {@link toProperNameCase} — their
|
|
||||||
* spelling is matched against the registered PAN name.
|
|
||||||
*/
|
|
||||||
export function toPersonNameCase(value: string | null | undefined): string {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return toProperNameCase(value.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export class CacheInterceptor implements HttpInterceptor {
|
|||||||
'/rbireservereturn',
|
'/rbireservereturn',
|
||||||
'/rbireserveconfig',
|
'/rbireserveconfig',
|
||||||
'/tdsconfig',
|
'/tdsconfig',
|
||||||
'/profitappropriationconfig',
|
|
||||||
'/tds/deposit-interest',
|
'/tds/deposit-interest',
|
||||||
// Declarations and challans are entered on the very screens that read them back.
|
// Declarations and challans are entered on the very screens that read them back.
|
||||||
'/tds/declarations',
|
'/tds/declarations',
|
||||||
@@ -54,12 +53,7 @@ export class CacheInterceptor implements HttpInterceptor {
|
|||||||
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
|
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
|
||||||
'/liens',
|
'/liens',
|
||||||
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
|
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
|
||||||
'/recurringdepositaccounts/dashboard',
|
'/recurringdepositaccounts/dashboard'
|
||||||
// Notes are also written by action screens (approve, close, deposit...), so a cached list would hide them.
|
|
||||||
'/notes',
|
|
||||||
// The ALM maturity breakdown counts deposits and loans as of the date asked and reads the bank's
|
|
||||||
// own bucket edges from the query; a cached copy would show another date's or another setting's figures.
|
|
||||||
'/almreturn'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
private isCacheable(url: string): boolean {
|
private isCacheable(url: string): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 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 { HttpContextToken } from '@angular/common/http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request opt-out of the top progress bar. Set via `{ context: new HttpContext().set(NO_PROGRESS_BAR, true) }`
|
||||||
|
* on background calls the user did not ask for (such as the Approvals badge listening for changes), which would
|
||||||
|
* otherwise keep the bar running or make it flash on a screen that has not changed.
|
||||||
|
*/
|
||||||
|
export const NO_PROGRESS_BAR = new HttpContextToken<boolean>(() => false);
|
||||||
@@ -16,6 +16,7 @@ import { finalize } from 'rxjs/operators';
|
|||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { ProgressBarService } from './progress-bar.service';
|
import { ProgressBarService } from './progress-bar.service';
|
||||||
|
import { NO_PROGRESS_BAR } from './no-progress.token';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Http Request interceptor to start/stop loading the progress bar.
|
* Http Request interceptor to start/stop loading the progress bar.
|
||||||
@@ -29,6 +30,9 @@ export class ProgressInterceptor implements HttpInterceptor {
|
|||||||
* and stop when a response or error is received.
|
* and stop when a response or error is received.
|
||||||
*/
|
*/
|
||||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||||
|
if (request.context.get(NO_PROGRESS_BAR)) {
|
||||||
|
return next.handle(request);
|
||||||
|
}
|
||||||
this.progressBarService.increase();
|
this.progressBarService.increase();
|
||||||
return next.handle(request).pipe(
|
return next.handle(request).pipe(
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
|
|||||||
@@ -407,15 +407,6 @@
|
|||||||
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
class="ni"
|
|
||||||
tabindex="0"
|
|
||||||
routerLink="/credit-bureau/dashboard"
|
|
||||||
routerLinkActive="active"
|
|
||||||
*mifosxHasPermission="'MENU_CREDIT_BUREAU'"
|
|
||||||
>
|
|
||||||
<fa-icon icon="id-card" class="ni-ico"></fa-icon>Credit Bureau
|
|
||||||
</a>
|
|
||||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||||
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
@@ -474,6 +465,11 @@
|
|||||||
*mifosxHasPermission="'MENU_TASKS'"
|
*mifosxHasPermission="'MENU_TASKS'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
|
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
|
||||||
|
@if (approvalTotal > 0) {
|
||||||
|
<span class="ni-badge" title="Requests waiting for approval">{{
|
||||||
|
approvalTotal > 99 ? '99+' : approvalTotal
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
|
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
|
||||||
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
|
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
|
||||||
@@ -517,16 +513,6 @@
|
|||||||
><span class="ni-short">Loans</span>
|
><span class="ni-short">Loans</span>
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
class="ni"
|
|
||||||
tabindex="0"
|
|
||||||
routerLink="/credit-bureau/dashboard"
|
|
||||||
routerLinkActive="active"
|
|
||||||
*mifosxHasPermission="'MENU_CREDIT_BUREAU'"
|
|
||||||
>
|
|
||||||
<fa-icon icon="id-card" class="ni-ico"></fa-icon><span class="ni-full">Credit Bureau</span
|
|
||||||
><span class="ni-short">CIC</span>
|
|
||||||
</a>
|
|
||||||
<a
|
<a
|
||||||
class="ni"
|
class="ni"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -1246,9 +1232,6 @@
|
|||||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/current']">
|
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/current']">
|
||||||
<fa-icon icon="building" class="menu-item-icon"></fa-icon> Current
|
<fa-icon icon="building" class="menu-item-icon"></fa-icon> Current
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/cc']">
|
|
||||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> CC
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/fixed-deposits']">
|
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/fixed-deposits']">
|
||||||
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Fixed Deposit
|
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Fixed Deposit
|
||||||
</button>
|
</button>
|
||||||
@@ -2058,11 +2041,11 @@
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesRbiSearch('ALM Average Liabilities and Assets Time Bucket Breakdown')"
|
[class.hidden]="!matchesRbiSearch('ALM')"
|
||||||
(click)="rbiMenuOpen = false; openAlmBucketBreakdownDialog()"
|
(click)="rbiMenuOpen = false; openAlmReportDialog()"
|
||||||
>
|
>
|
||||||
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Average Liabilities
|
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Asset Liability
|
||||||
and Assets) – Time Bucket Breakdown
|
Management)
|
||||||
</a>
|
</a>
|
||||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
|
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
|
||||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
|
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
|
||||||
|
|||||||
@@ -1082,3 +1082,18 @@ $gray-700: #334155;
|
|||||||
width: $bar;
|
width: $bar;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How many requests are waiting under Approvals.
|
||||||
|
.ni-badge {
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { TenantMenuAccessService } from '../../directives/has-permission/tenant-
|
|||||||
|
|
||||||
/** rxjs Imports */
|
/** rxjs Imports */
|
||||||
import { Observable, Subscription } from 'rxjs';
|
import { Observable, Subscription } from 'rxjs';
|
||||||
|
import { ApprovalCountsService } from '../../tasks/approval-counts.service';
|
||||||
import { map, filter } from 'rxjs/operators';
|
import { map, filter } from 'rxjs/operators';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
@@ -68,6 +69,11 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
private authService = inject(AuthenticationService);
|
private authService = inject(AuthenticationService);
|
||||||
private clerkUiStateService = inject(ClerkUiStateService);
|
private clerkUiStateService = inject(ClerkUiStateService);
|
||||||
private tenantMenuAccessService = inject(TenantMenuAccessService);
|
private tenantMenuAccessService = inject(TenantMenuAccessService);
|
||||||
|
private approvalCounts = inject(ApprovalCountsService);
|
||||||
|
|
||||||
|
/** Requests waiting across every Approval Center tab this user can see, shown on the Approvals menu. */
|
||||||
|
approvalTotal = 0;
|
||||||
|
private approvalTotalSub?: Subscription;
|
||||||
|
|
||||||
/** Subscription to breakpoint observer for handset. */
|
/** Subscription to breakpoint observer for handset. */
|
||||||
isHandset$: Observable<boolean> = this.breakpointObserver
|
isHandset$: Observable<boolean> = this.breakpointObserver
|
||||||
@@ -144,6 +150,13 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
* Subscribes to progress bar to update its mode.
|
* Subscribes to progress bar to update its mode.
|
||||||
*/
|
*/
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
// The Approvals badge: counts refresh every minute while the app is open, and again whenever the Approval Center opens.
|
||||||
|
this.approvalTotalSub = this.approvalCounts.total$.subscribe((total) => {
|
||||||
|
this.approvalTotal = total;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
});
|
||||||
|
this.approvalCounts.start();
|
||||||
|
|
||||||
this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => {
|
this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => {
|
||||||
this.progressBarMode = mode;
|
this.progressBarMode = mode;
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
@@ -364,29 +377,12 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the ALM Time-Bucket Breakdown: which balance-sheet item goes to which bucket of the
|
|
||||||
* Structural Liquidity Statement. Rules are editable and kept per browser.
|
|
||||||
*/
|
|
||||||
async openAlmBucketBreakdownDialog(): Promise<void> {
|
|
||||||
const { AlmBucketBreakdownDialogComponent } =
|
|
||||||
await import('../../home/dashboard/alm-bucket-breakdown-dialog/alm-bucket-breakdown-dialog.component');
|
|
||||||
this.dialog.open(AlmBucketBreakdownDialogComponent, {
|
|
||||||
width: '94vw',
|
|
||||||
maxWidth: '1320px',
|
|
||||||
maxHeight: '92vh',
|
|
||||||
panelClass: 'crr-slr-dialog-panel',
|
|
||||||
autoFocus: false,
|
|
||||||
disableClose: false,
|
|
||||||
hasBackdrop: true,
|
|
||||||
backdropClass: 'alm-dlg-backdrop'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unsubscribes from progress bar.
|
* Unsubscribes from progress bar.
|
||||||
*/
|
*/
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
this.approvalTotalSub?.unsubscribe();
|
||||||
|
this.approvalCounts.stop();
|
||||||
if (this.progressBar$) {
|
if (this.progressBar$) {
|
||||||
this.progressBar$.unsubscribe();
|
this.progressBar$.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@
|
|||||||
<fa-icon icon="hand-holding-usd" class="ai-col-h-ic"></fa-icon>
|
<fa-icon icon="hand-holding-usd" class="ai-col-h-ic"></fa-icon>
|
||||||
<span>Lending & Risk</span>
|
<span>Lending & Risk</span>
|
||||||
</div>
|
</div>
|
||||||
<button mat-menu-item class="ai-card" routerLink="/ai/loan-eligibility" tabindex="0">
|
<button mat-menu-item class="ai-card" tabindex="0">
|
||||||
<span class="ai-card-ic"><fa-icon icon="hand-holding-usd"></fa-icon></span>
|
<span class="ai-card-ic"><fa-icon icon="hand-holding-usd"></fa-icon></span>
|
||||||
<span class="ai-card-body">
|
<span class="ai-card-body">
|
||||||
<span class="ai-card-t">Loan Eligibility</span>
|
<span class="ai-card-t">Loan Eligibility</span>
|
||||||
@@ -239,27 +239,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ai-col ai-col--blue">
|
|
||||||
<div class="ai-col-h">
|
|
||||||
<fa-icon icon="sitemap" class="ai-col-h-ic"></fa-icon>
|
|
||||||
<span>API Management</span>
|
|
||||||
</div>
|
|
||||||
<button mat-menu-item class="ai-card" routerLink="/api-management/vendors" tabindex="0">
|
|
||||||
<span class="ai-card-ic"><fa-icon icon="cogs"></fa-icon></span>
|
|
||||||
<span class="ai-card-body">
|
|
||||||
<span class="ai-card-t">API Vendors & Setup</span>
|
|
||||||
<span class="ai-card-d">Vendor, work and API set — IN / OUT (CIC bureaus, SMS, WhatsApp …)</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item class="ai-card" routerLink="/api-management/logs" tabindex="0">
|
|
||||||
<span class="ai-card-ic"><fa-icon icon="history"></fa-icon></span>
|
|
||||||
<span class="ai-card-body">
|
|
||||||
<span class="ai-card-t">API Call Logs</span>
|
|
||||||
<span class="ai-card-d">Every IN and OUT call with request and response</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ai-menu-footer">
|
<div class="ai-menu-footer">
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
|||||||
// ===== AI Mega Menu Panel =====
|
// ===== AI Mega Menu Panel =====
|
||||||
::ng-deep .ai-menu {
|
::ng-deep .ai-menu {
|
||||||
&.mat-mdc-menu-panel {
|
&.mat-mdc-menu-panel {
|
||||||
min-width: 1080px !important;
|
min-width: 880px !important;
|
||||||
max-width: 96vw !important;
|
max-width: 96vw !important;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
border-radius: 14px !important;
|
border-radius: 14px !important;
|
||||||
@@ -520,7 +520,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
|||||||
// ===== Mega Layout — 4 colour-coded columns =====
|
// ===== Mega Layout — 4 colour-coded columns =====
|
||||||
.ai-menu-cols {
|
.ai-menu-cols {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
padding: 14px 14px 6px;
|
padding: 14px 14px 6px;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
@@ -567,12 +567,6 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
|||||||
--c-hover: #be185d;
|
--c-hover: #be185d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-col--blue {
|
|
||||||
--c-color: #0369a1;
|
|
||||||
--c-bg: #e0f2fe;
|
|
||||||
--c-hover: #0369a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ai-col-h {
|
.ai-col-h {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -708,7 +702,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Responsive collapse
|
// Responsive collapse
|
||||||
@media (width <= 1300px) {
|
@media (width <= 1100px) {
|
||||||
::ng-deep .ai-menu.mat-mdc-menu-panel {
|
::ng-deep .ai-menu.mat-mdc-menu-panel {
|
||||||
min-width: 680px !important;
|
min-width: 680px !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,252 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="cic-modal-backdrop" (click)="closed.emit()">
|
|
||||||
<div class="cic-modal" style="width: min(980px, 100%)" (click)="$event.stopPropagation()">
|
|
||||||
@if (loading && !alert) {
|
|
||||||
<div class="cic-empty">Loading…</div>
|
|
||||||
}
|
|
||||||
@if (alert) {
|
|
||||||
@if (history.length) {
|
|
||||||
<div class="cic-alert info" style="margin-bottom: 12px">
|
|
||||||
You are viewing another enquiry of this customer.
|
|
||||||
<button class="cic-btn sm" style="margin-left: 8px" (click)="back()">← Back to previous enquiry</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: flex-start">
|
|
||||||
<div>
|
|
||||||
<h3 style="margin-bottom: 4px">
|
|
||||||
{{ alert.clientName || alert.customerName || 'Unknown customer' }} applied for
|
|
||||||
{{ alert.loanType || 'a loan' }} elsewhere
|
|
||||||
</h3>
|
|
||||||
<div class="muted" style="font-size: 12px">
|
|
||||||
{{ alert.bureauCode }} alert · {{ alert.alertKey }} · received {{ alert.receivedOn | slice: 0 : 16 }} via
|
|
||||||
{{ alert.receivedVia }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-actions">
|
|
||||||
<span class="cic-badge" [class]="'cic-badge ' + statusTone(alert.status)">{{ alert.status }}</span>
|
|
||||||
<button class="cic-btn sm" (click)="closed.emit()">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (error) {
|
|
||||||
<div class="cic-alert error" style="margin-top: 12px">{{ error }}</div>
|
|
||||||
}
|
|
||||||
@if (info) {
|
|
||||||
<div class="cic-alert success" style="margin-top: 12px">{{ info }}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cic-grid cols-3" style="margin-top: 14px">
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Loan applied for</div>
|
|
||||||
<div class="v" style="font-size: 17px">{{ alert.loanType || '—' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Amount</div>
|
|
||||||
<div class="v" style="font-size: 20px">
|
|
||||||
{{ alert.amount !== null && alert.amount !== undefined ? '₹' + (alert.amount | number: '1.0-0') : '—' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Enquiry date</div>
|
|
||||||
<div class="v" style="font-size: 17px">{{ alert.enquiryDate | date: 'dd MMM yyyy' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cic-split" style="margin-top: 14px">
|
|
||||||
<div class="cic-card" style="margin-bottom: 0">
|
|
||||||
<h2>
|
|
||||||
Enquiry elsewhere
|
|
||||||
<span class="cic-card-sub">the CIC does not name the lender</span>
|
|
||||||
</h2>
|
|
||||||
<dl class="cic-kv" style="grid-template-columns: 140px minmax(0, 1fr)">
|
|
||||||
<dt>Loan type</dt>
|
|
||||||
<dd>{{ alert.loanType || '—' }}</dd>
|
|
||||||
<dt>Amount</dt>
|
|
||||||
<dd>
|
|
||||||
{{ alert.amount !== null && alert.amount !== undefined ? '₹' + (alert.amount | number: '1.0-2') : '—' }}
|
|
||||||
</dd>
|
|
||||||
<dt>Purpose</dt>
|
|
||||||
<dd>{{ alert.purpose || '—' }}</dd>
|
|
||||||
<dt>Place</dt>
|
|
||||||
<dd>{{ alert.location || '—' }}</dd>
|
|
||||||
<dt>Enquiry date</dt>
|
|
||||||
<dd>{{ alert.enquiryDate | date: 'dd MMM yyyy' }}</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
<div class="cic-card" style="margin-bottom: 0">
|
|
||||||
<h2>
|
|
||||||
Our customer
|
|
||||||
@if (!alert.clientId) {
|
|
||||||
<button
|
|
||||||
class="cic-btn sm primary"
|
|
||||||
(click)="linkCustomer()"
|
|
||||||
[disabled]="saving"
|
|
||||||
*mifosxHasPermission="'UPDATE_CICALERT'"
|
|
||||||
>
|
|
||||||
Link to customer
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</h2>
|
|
||||||
<dl class="cic-kv" style="grid-template-columns: 140px minmax(0, 1fr)">
|
|
||||||
<dt>Customer</dt>
|
|
||||||
<dd>
|
|
||||||
@if (alert.clientId) {
|
|
||||||
<a [routerLink]="['/clients', alert.clientId, 'general']" (click)="closed.emit()">{{
|
|
||||||
alert.clientName
|
|
||||||
}}</a>
|
|
||||||
<span class="muted"> · {{ alert.clientAccountNo }}</span>
|
|
||||||
} @else {
|
|
||||||
<span class="tone-bad">Not matched to any customer</span>
|
|
||||||
}
|
|
||||||
</dd>
|
|
||||||
<dt>Branch</dt>
|
|
||||||
<dd>{{ alert.officeName || '—' }}</dd>
|
|
||||||
<dt>Matched by</dt>
|
|
||||||
<dd>{{ matchLabels[alert.matchType] || alert.matchType }}</dd>
|
|
||||||
<dt>Name at bureau</dt>
|
|
||||||
<dd>{{ alert.customerName || '—' }}</dd>
|
|
||||||
<dt>PAN / Aadhaar</dt>
|
|
||||||
<dd class="cic-mono">{{ alert.panMasked || '—' }} / {{ alert.aadhaarMasked || '—' }}</dd>
|
|
||||||
@if (alert.customerRef) {
|
|
||||||
<dt>Reference</dt>
|
|
||||||
<dd class="cic-mono">{{ alert.customerRef }}</dd>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (alert.clientId) {
|
|
||||||
<div class="cic-card" style="margin-top: 14px; margin-bottom: 0">
|
|
||||||
<h2>
|
|
||||||
Customer's loans with us
|
|
||||||
<span class="cic-card-sub">outstanding ₹{{ exposure | number: '1.0-0' }}</span>
|
|
||||||
</h2>
|
|
||||||
@if (alert.clientLoans?.length) {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Account</th>
|
|
||||||
<th>Product</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th class="num">Principal</th>
|
|
||||||
<th class="num">Outstanding</th>
|
|
||||||
<th>Disbursed</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (l of alert.clientLoans; track l.id) {
|
|
||||||
<tr>
|
|
||||||
<td class="cic-mono">{{ l.accountNo }}</td>
|
|
||||||
<td>{{ l.productName }}</td>
|
|
||||||
<td>{{ l.status }}</td>
|
|
||||||
<td class="num">{{ l.principal | number: '1.0-0' }}</td>
|
|
||||||
<td class="num">{{ l.outstanding | number: '1.0-0' }}</td>
|
|
||||||
<td>{{ l.disbursedOn | date: 'dd MMM yyyy' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="muted" style="font-size: 13px">
|
|
||||||
No loan with us. A customer with deposits only who is borrowing elsewhere may be a cross-sell opportunity.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (alert.otherAlertsOfCustomer?.length) {
|
|
||||||
<div class="cic-card" style="margin-top: 14px; margin-bottom: 0">
|
|
||||||
<h2>
|
|
||||||
All enquiries on this customer
|
|
||||||
<span class="cic-card-sub">{{ customerEnquiries.length }} in total</span>
|
|
||||||
</h2>
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Loan type</th>
|
|
||||||
<th class="num">Amount</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (o of customerEnquiries; track o.id) {
|
|
||||||
<tr
|
|
||||||
class="clickable"
|
|
||||||
[style.background]="o.id === alert.id ? '#eef4ff' : null"
|
|
||||||
(click)="o.id !== alert.id && openOther(o.id)"
|
|
||||||
>
|
|
||||||
<td>{{ o.enquiryDate | date: 'dd MMM yyyy' }}</td>
|
|
||||||
<td>
|
|
||||||
{{ o.loanType }}
|
|
||||||
@if (o.id === alert.id) {
|
|
||||||
<b>(shown above)</b>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ o.amount | number: '1.0-0' }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class]="'cic-badge ' + statusTone(o.status)">{{ o.status }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (alert.remarks || alert.actionedByName) {
|
|
||||||
<div class="cic-alert info" style="margin-top: 14px">
|
|
||||||
<b>{{ alert.status }}</b> by {{ alert.actionedByName }} on {{ alert.actionedOn | slice: 0 : 16 }}
|
|
||||||
@if (alert.remarks) {
|
|
||||||
— {{ alert.remarks }}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<ng-container *mifosxHasPermission="'UPDATE_CICALERT'">
|
|
||||||
@if (action) {
|
|
||||||
<div class="cic-field" style="margin-top: 14px">
|
|
||||||
<label>{{ action === 'ACTIONED' ? 'What was done' : 'Reason for dismissing' }}</label>
|
|
||||||
<textarea rows="3" [(ngModel)]="remarks" maxlength="1000"></textarea>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="foot">
|
|
||||||
@if (action) {
|
|
||||||
<button class="cic-btn" (click)="action = null">Cancel</button>
|
|
||||||
<button class="cic-btn primary" (click)="confirmAction()" [disabled]="saving">
|
|
||||||
{{ action === 'ACTIONED' ? 'Save as actioned' : 'Dismiss alert' }}
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
@if (alert.status === 'ACTIONED' || alert.status === 'DISMISSED') {
|
|
||||||
<button class="cic-btn" (click)="setStatus('SEEN', null)" [disabled]="saving">Reopen</button>
|
|
||||||
}
|
|
||||||
@if (alert.status !== 'DISMISSED') {
|
|
||||||
<button class="cic-btn danger" (click)="action = 'DISMISSED'">Dismiss</button>
|
|
||||||
}
|
|
||||||
@if (alert.status !== 'ACTIONED') {
|
|
||||||
<button class="cic-btn primary" (click)="action = 'ACTIONED'">Mark actioned</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<div style="margin-top: 12px">
|
|
||||||
<button class="cic-btn sm" (click)="showRaw = !showRaw">{{ showRaw ? 'Hide' : 'Show' }} bureau data</button>
|
|
||||||
@if (showRaw) {
|
|
||||||
<pre class="cic-code" style="margin-top: 8px">{{ rawText() }}</pre>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { CommonModule } from '@angular/common';
|
|
||||||
import { Component, EventEmitter, Input, OnChanges, Output, inject } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
|
|
||||||
import {
|
|
||||||
CustomerSearchDialogComponent,
|
|
||||||
CustomerSearchResult
|
|
||||||
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
|
|
||||||
import { CIC_MATCH_LABELS, CicAlert, CicService, cicError, cicStatusTone } from '../cic.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One enquiry alert: which of our customers applied elsewhere (the CIC does not name the lender), for what loan and
|
|
||||||
* how much; the customer's
|
|
||||||
* loans with us and earlier alerts; and the follow-up (seen / actioned / dismissed, or linking an unmatched alert).
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-cic-alert-detail',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
RouterModule,
|
|
||||||
MatDialogModule,
|
|
||||||
HasPermissionDirective
|
|
||||||
],
|
|
||||||
templateUrl: './cic-alert-detail.component.html',
|
|
||||||
styleUrls: ['../cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class CicAlertDetailComponent implements OnChanges {
|
|
||||||
private cic = inject(CicService);
|
|
||||||
private dialog = inject(MatDialog);
|
|
||||||
|
|
||||||
@Input() alertId: number | null = null;
|
|
||||||
@Output() closed = new EventEmitter<void>();
|
|
||||||
@Output() changed = new EventEmitter<CicAlert>();
|
|
||||||
|
|
||||||
alert: CicAlert | null = null;
|
|
||||||
loading = false;
|
|
||||||
saving = false;
|
|
||||||
error: string | null = null;
|
|
||||||
info: string | null = null;
|
|
||||||
showRaw = false;
|
|
||||||
|
|
||||||
/** Alerts visited before the current one, so the user can go back after opening another enquiry of the customer. */
|
|
||||||
history: number[] = [];
|
|
||||||
private autoSeen = true;
|
|
||||||
|
|
||||||
action: 'ACTIONED' | 'DISMISSED' | null = null;
|
|
||||||
remarks = '';
|
|
||||||
|
|
||||||
readonly matchLabels = CIC_MATCH_LABELS;
|
|
||||||
readonly statusTone = cicStatusTone;
|
|
||||||
|
|
||||||
ngOnChanges(): void {
|
|
||||||
this.history = [];
|
|
||||||
this.autoSeen = true;
|
|
||||||
if (this.alertId) this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
openOther(id: number): void {
|
|
||||||
if (this.alertId) this.history.push(this.alertId);
|
|
||||||
this.alertId = id;
|
|
||||||
// Only the alert opened from the dashboard counts as "seen"; browsing the customer's other enquiries changes nothing.
|
|
||||||
this.autoSeen = false;
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
back(): void {
|
|
||||||
const id = this.history.pop();
|
|
||||||
if (!id) return;
|
|
||||||
this.alertId = id;
|
|
||||||
this.autoSeen = false;
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
if (!this.alertId) return;
|
|
||||||
this.loading = true;
|
|
||||||
this.error = null;
|
|
||||||
this.info = null;
|
|
||||||
this.action = null;
|
|
||||||
this.remarks = '';
|
|
||||||
this.cic.alert(this.alertId).subscribe({
|
|
||||||
next: (a) => {
|
|
||||||
this.alert = a;
|
|
||||||
this.loading = false;
|
|
||||||
setTimeout(() => document.querySelector('.cic-modal')?.scrollTo({ top: 0 }));
|
|
||||||
// Opening a new alert is seeing it.
|
|
||||||
if (this.autoSeen && a.status === 'NEW' && a.clientId) this.setStatus('SEEN', null, true);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.loading = false;
|
|
||||||
this.error = cicError(e, 'Could not load the alert.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(status: string, remarks: string | null, quiet = false): void {
|
|
||||||
if (!this.alert) return;
|
|
||||||
this.saving = true;
|
|
||||||
this.error = null;
|
|
||||||
this.cic.updateStatus(this.alert.id, status, remarks).subscribe({
|
|
||||||
next: (a) => {
|
|
||||||
this.saving = false;
|
|
||||||
this.alert = a;
|
|
||||||
this.action = null;
|
|
||||||
this.remarks = '';
|
|
||||||
if (!quiet) this.info = `Alert marked ${status.toLowerCase()}.`;
|
|
||||||
this.changed.emit(a);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.saving = false;
|
|
||||||
// A user without UPDATE_CICALERT can still read; the automatic "seen" is then simply skipped.
|
|
||||||
if (!quiet) this.error = cicError(e, 'Could not update the alert.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmAction(): void {
|
|
||||||
if (!this.action) return;
|
|
||||||
if (!this.remarks.trim()) {
|
|
||||||
this.error =
|
|
||||||
this.action === 'ACTIONED'
|
|
||||||
? 'Write what was done (customer contacted, retention offer made, limit reviewed ...).'
|
|
||||||
: 'Write why the alert is dismissed.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setStatus(this.action, this.remarks.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
linkCustomer(): void {
|
|
||||||
if (!this.alert) return;
|
|
||||||
this.dialog
|
|
||||||
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
|
|
||||||
.afterClosed()
|
|
||||||
.subscribe((r: CustomerSearchResult | undefined) => {
|
|
||||||
if (!r?.clientId || !this.alert) return;
|
|
||||||
this.saving = true;
|
|
||||||
this.cic.linkClient(this.alert.id, r.clientId).subscribe({
|
|
||||||
next: (a) => {
|
|
||||||
this.saving = false;
|
|
||||||
this.alert = a;
|
|
||||||
this.info = `Alert linked to ${a.clientName}.`;
|
|
||||||
this.changed.emit(a);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.saving = false;
|
|
||||||
this.error = cicError(e, 'Could not link the customer.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All of the customer's enquiries, this one included, in a fixed order so the list does not reshuffle when one is opened. */
|
|
||||||
get customerEnquiries(): CicAlert[] {
|
|
||||||
if (!this.alert) return [];
|
|
||||||
return [
|
|
||||||
this.alert,
|
|
||||||
...(this.alert.otherAlertsOfCustomer ?? [])
|
|
||||||
].sort((x, y) => (y.enquiryDate ?? '').localeCompare(x.enquiryDate ?? '') || y.id - x.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
get exposure(): number {
|
|
||||||
return (this.alert?.clientLoans ?? []).reduce((s, l) => s + Number(l.outstanding ?? 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
rawText(): string {
|
|
||||||
return JSON.stringify(this.alert?.raw ?? {}, null, 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="cic-page">
|
|
||||||
<div class="cic-head">
|
|
||||||
<div>
|
|
||||||
<h1>Credit Bureau — Customer Enquiry Alerts</h1>
|
|
||||||
<p>
|
|
||||||
Our customers who applied for a loan at another bank or NBFC, as reported by the CIC (CIBIL, Experian, Equifax,
|
|
||||||
CRIF): what loan and how much. The CIC does not tell which bank or lender it was.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="cic-actions">
|
|
||||||
<select [(ngModel)]="days" (ngModelChange)="load()" class="cic-btn" style="padding-right: 8px">
|
|
||||||
<option [ngValue]="7">Last 7 days</option>
|
|
||||||
<option [ngValue]="30">Last 30 days</option>
|
|
||||||
<option [ngValue]="90">Last 90 days</option>
|
|
||||||
<option [ngValue]="180">Last 180 days</option>
|
|
||||||
</select>
|
|
||||||
<a class="cic-btn" routerLink="/credit-bureau/fetch-log">Fetch log</a>
|
|
||||||
@if (whitelisted.length) {
|
|
||||||
<button
|
|
||||||
class="cic-btn primary"
|
|
||||||
(click)="fetchNow('ALL')"
|
|
||||||
[disabled]="fetching !== null"
|
|
||||||
*mifosxHasPermission="'FETCH_CICALERT'"
|
|
||||||
>
|
|
||||||
{{ fetching === 'ALL' ? 'Fetching…' : 'Fetch alerts now' }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (error) {
|
|
||||||
<div class="cic-alert error">{{ error }}</div>
|
|
||||||
}
|
|
||||||
@if (info) {
|
|
||||||
<div class="cic-alert success">{{ info }}</div>
|
|
||||||
}
|
|
||||||
@if (data && !whitelisted.length) {
|
|
||||||
<div class="cic-alert warn">
|
|
||||||
No CIC is whitelisted yet, so no alerts are being received. Switch a bureau on (CIBIL, Experian, Equifax or CRIF)
|
|
||||||
under AI / Intelligence Modules → API Management → API Vendors & Setup.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (loading && !data) {
|
|
||||||
<div class="cic-card cic-empty">Loading…</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (data) {
|
|
||||||
<div class="cic-grid cols-4" style="margin-bottom: 16px">
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">New alerts</div>
|
|
||||||
<div class="v" [class.tone-bad]="data.newAlerts > 0">{{ data.newAlerts }}</div>
|
|
||||||
<div class="s">not yet looked at</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Customers enquired elsewhere</div>
|
|
||||||
<div class="v">{{ data.customersInPeriod }}</div>
|
|
||||||
<div class="s">{{ data.alertsInPeriod }} enquiries · last {{ days }} days</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Amount applied elsewhere</div>
|
|
||||||
<div class="v">₹{{ data.amountInPeriod | number: '1.0-0' }}</div>
|
|
||||||
<div class="s">in total · last {{ days }} days</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Unmatched alerts</div>
|
|
||||||
<div class="v" [class.tone-warn]="data.unmatchedOpen > 0">{{ data.unmatchedOpen }}</div>
|
|
||||||
<div class="s">bureau sent, customer not found · {{ data.receivedToday }} received today</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent -->
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>Latest enquiries on our customers</h2>
|
|
||||||
@if (data.recent?.length) {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Enquiry date</th>
|
|
||||||
<th>Our customer</th>
|
|
||||||
<th>Branch</th>
|
|
||||||
<th>Loan type</th>
|
|
||||||
<th class="num">Amount (₹)</th>
|
|
||||||
<th>Purpose</th>
|
|
||||||
<th>Bureau</th>
|
|
||||||
<th>Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (a of data.recent; track a.id) {
|
|
||||||
<tr class="clickable" (click)="openAlertId = a.id">
|
|
||||||
<td>{{ a.enquiryDate | date: 'dd MMM yyyy' }}</td>
|
|
||||||
<td>
|
|
||||||
<b>{{ a.clientName }}</b>
|
|
||||||
<div class="muted" style="font-size: 11px">{{ a.clientAccountNo }}</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ a.officeName }}</td>
|
|
||||||
<td>{{ a.loanType }}</td>
|
|
||||||
<td class="num">{{ a.amount | number: '1.0-0' }}</td>
|
|
||||||
<td>{{ a.purpose }}</td>
|
|
||||||
<td>{{ a.bureauCode }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class]="'cic-badge ' + statusTone(a.status)">{{ a.status }}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-empty">
|
|
||||||
No enquiry alerts yet. They appear here as soon as a CIC reports one of our customers.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cic-split">
|
|
||||||
<!-- Repeat customers -->
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>
|
|
||||||
Customers applying elsewhere repeatedly
|
|
||||||
<span class="cic-card-sub">2+ enquiries · last {{ days }} days</span>
|
|
||||||
</h2>
|
|
||||||
@if (data.repeatCustomers?.length) {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Customer</th>
|
|
||||||
<th class="num">Enquiries</th>
|
|
||||||
<th class="num">Total applied (₹)</th>
|
|
||||||
<th>Last</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (c of data.repeatCustomers; track c.clientId) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<b>{{ c.clientName }}</b>
|
|
||||||
<div class="muted" style="font-size: 11px">{{ c.accountNo }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="num tone-bad">
|
|
||||||
<b>{{ c.alerts }}</b>
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ c.amount | number: '1.0-0' }}</td>
|
|
||||||
<td>{{ c.lastEnquiryDate | date: 'dd MMM' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-empty">No customer has more than one enquiry in this period.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Trend -->
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>Enquiries per day <span class="cic-card-sub">last 14 days</span></h2>
|
|
||||||
<div class="cic-trend">
|
|
||||||
@for (p of data.trend14Days; track p.date) {
|
|
||||||
<div
|
|
||||||
class="col"
|
|
||||||
[title]="(p.date | date: 'dd MMM') + ': ' + p.count + ' enquiries, ₹' + (p.amount | number: '1.0-0')"
|
|
||||||
>
|
|
||||||
<span class="n">{{ p.count || '' }}</span>
|
|
||||||
<div class="bar" [style.height.%]="pct(p.count, data.trend14Days, 'count')"></div>
|
|
||||||
<span class="d">{{ p.date | date: 'dd' }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="cic-card" style="margin-bottom: 0">
|
|
||||||
<h2>By loan type</h2>
|
|
||||||
@for (g of data.byLoanType; track g.label) {
|
|
||||||
<div style="margin-bottom: 10px">
|
|
||||||
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px">
|
|
||||||
<span>{{ g.label }}</span>
|
|
||||||
<span class="muted">{{ g.count }} · ₹{{ g.amount | number: '1.0-0' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="cic-bar"><div [style.width.%]="pct(g.count, data.byLoanType, 'count')"></div></div>
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<div class="cic-empty">No data.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bureau fetch status -->
|
|
||||||
<div class="cic-card" style="margin-top: 16px">
|
|
||||||
<h2>
|
|
||||||
Alert feed from each CIC
|
|
||||||
<a class="cic-btn sm" routerLink="/credit-bureau/fetch-log">Fetch log</a>
|
|
||||||
</h2>
|
|
||||||
<div class="cic-grid cols-4">
|
|
||||||
@for (b of data.bureaus; track b.id) {
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; gap: 6px">
|
|
||||||
<b>{{ b.code }}</b>
|
|
||||||
<span class="cic-badge" [class.good]="b.whitelisted">{{ b.whitelisted ? 'Whitelisted' : 'Off' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="s" style="margin-top: 6px">
|
|
||||||
{{ b.environment }} · {{ b.autoFetch ? 'auto every ' + b.intervalMin + ' min' : 'manual fetch only' }}
|
|
||||||
</div>
|
|
||||||
<div class="s">
|
|
||||||
Last fetch:
|
|
||||||
@if (b.lastFetchOn) {
|
|
||||||
<span class="cic-badge" [class]="'cic-badge ' + fetchTone(b.lastFetchStatus)">{{
|
|
||||||
b.lastFetchStatus
|
|
||||||
}}</span>
|
|
||||||
{{ b.lastFetchOn | slice: 0 : 16 }}
|
|
||||||
} @else {
|
|
||||||
never
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (b.lastFetchMessage) {
|
|
||||||
<div class="s" style="font-size: 11px">{{ b.lastFetchMessage }}</div>
|
|
||||||
}
|
|
||||||
<div class="s" style="font-size: 11px; margin-top: 4px">
|
|
||||||
@if (b.environment === 'SIMULATOR') {
|
|
||||||
Simulator: no call goes to {{ b.code }}; the alerts are made up by the CBS for testing.
|
|
||||||
} @else {
|
|
||||||
Calls {{ b.code }} {{ b.environment }} with the API set up under API Management.
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (b.whitelisted) {
|
|
||||||
<button
|
|
||||||
class="cic-btn sm"
|
|
||||||
style="margin-top: 8px"
|
|
||||||
(click)="fetchNow(b.id)"
|
|
||||||
[disabled]="fetching !== null"
|
|
||||||
*mifosxHasPermission="'FETCH_CICALERT'"
|
|
||||||
>
|
|
||||||
{{ fetching === b.id ? 'Fetching…' : 'Fetch now' }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<a class="cic-btn sm" style="margin-top: 8px; margin-left: 4px" [routerLink]="setupRoute(b.code)">Set up</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (openAlertId) {
|
|
||||||
<mifosx-cic-alert-detail
|
|
||||||
[alertId]="openAlertId"
|
|
||||||
(closed)="openAlertId = null"
|
|
||||||
(changed)="onChanged()"
|
|
||||||
></mifosx-cic-alert-detail>
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { CommonModule } from '@angular/common';
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { ApiManagementService } from 'app/api-management/api-management.service';
|
|
||||||
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
|
|
||||||
import { CicAlertDetailComponent } from '../cic-alert-detail/cic-alert-detail.component';
|
|
||||||
import { CicService, cicError, cicStatusTone } from '../cic.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Credit Bureau dashboard: our customers who applied for a loan at another bank / NBFC, as reported by the CICs -
|
|
||||||
* who, what loan and how much (the CIC does not name the lender) - with the customers applying elsewhere repeatedly,
|
|
||||||
* the loan types, the trend, and the fetch status of each bureau. Each bureau is set up under API Management → API
|
|
||||||
* Vendors & Setup.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-cic-dashboard',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
RouterModule,
|
|
||||||
HasPermissionDirective,
|
|
||||||
CicAlertDetailComponent
|
|
||||||
],
|
|
||||||
templateUrl: './cic-dashboard.component.html',
|
|
||||||
styleUrls: ['../cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class CicDashboardComponent implements OnInit {
|
|
||||||
private cic = inject(CicService);
|
|
||||||
private apis = inject(ApiManagementService);
|
|
||||||
|
|
||||||
/** Each bureau is set up as a vendor under API Management (connection, whitelist, request / response mapping). */
|
|
||||||
setupRoute(code: string): any[] {
|
|
||||||
const v = this.apis.vendorByCode(code);
|
|
||||||
return v ? [
|
|
||||||
'/api-management/vendors',
|
|
||||||
v.id
|
|
||||||
] : ['/api-management/vendors'];
|
|
||||||
}
|
|
||||||
|
|
||||||
days = 30;
|
|
||||||
loading = false;
|
|
||||||
error: string | null = null;
|
|
||||||
info: string | null = null;
|
|
||||||
data: any = null;
|
|
||||||
fetching: number | 'ALL' | null = null;
|
|
||||||
openAlertId: number | null = null;
|
|
||||||
|
|
||||||
readonly statusTone = cicStatusTone;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.error = null;
|
|
||||||
this.cic.dashboard(this.days).subscribe({
|
|
||||||
next: (d) => {
|
|
||||||
this.data = d;
|
|
||||||
this.loading = false;
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.error = cicError(e, 'Could not load the credit bureau dashboard.');
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get whitelisted(): any[] {
|
|
||||||
return (this.data?.bureaus ?? []).filter((b: any) => b.whitelisted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch now from one bureau, or from every whitelisted bureau in turn. */
|
|
||||||
fetchNow(bureauId: number | 'ALL'): void {
|
|
||||||
const ids = bureauId === 'ALL' ? this.whitelisted.map((b) => b.id as number) : [bureauId];
|
|
||||||
if (!ids.length) return;
|
|
||||||
this.fetching = bureauId;
|
|
||||||
this.error = null;
|
|
||||||
this.info = null;
|
|
||||||
const messages: string[] = [];
|
|
||||||
const next = (i: number) => {
|
|
||||||
if (i >= ids.length) {
|
|
||||||
this.fetching = null;
|
|
||||||
this.info = messages.join(' · ');
|
|
||||||
this.load();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.cic.fetch(ids[i]).subscribe({
|
|
||||||
next: (run) => {
|
|
||||||
messages.push(
|
|
||||||
`${run.bureauCode}: ${run.status === 'SUCCESS' ? run.newAlerts + ' new alert(s)' : 'failed - ' + run.message}`
|
|
||||||
);
|
|
||||||
next(i + 1);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
messages.push(cicError(e, 'Fetch failed.'));
|
|
||||||
next(i + 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
next(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
max(list: any[] | undefined, key: string): number {
|
|
||||||
return Math.max(1, ...(list ?? []).map((x) => Number(x[key] ?? 0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
pct(value: any, list: any[] | undefined, key: string): number {
|
|
||||||
return Math.round((Number(value ?? 0) / this.max(list, key)) * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChanged(): void {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchTone(status?: string): string {
|
|
||||||
return status === 'SUCCESS' ? 'good' : status === 'FAILED' ? 'bad' : '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="cic-page">
|
|
||||||
<div class="cic-head">
|
|
||||||
<div>
|
|
||||||
<h1>Fetch Log</h1>
|
|
||||||
<p>Every time the CBS asked a CIC for enquiry alerts: when, how many came, how many were our customers.</p>
|
|
||||||
</div>
|
|
||||||
<div class="cic-actions">
|
|
||||||
<a class="cic-btn" routerLink="/credit-bureau/dashboard">← Dashboard</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (error) {
|
|
||||||
<div class="cic-alert error">{{ error }}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>
|
|
||||||
Fetches
|
|
||||||
<span class="cic-actions">
|
|
||||||
<select [(ngModel)]="bureauId" (ngModelChange)="filterChanged()" class="cic-btn sm">
|
|
||||||
<option value="">All bureaus</option>
|
|
||||||
@for (b of bureaus; track b.id) {
|
|
||||||
<option [value]="b.id">{{ b.code }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
<button class="cic-btn sm" (click)="load()">Refresh</button>
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
@if (loading && !runs.length) {
|
|
||||||
<div class="cic-empty">Loading…</div>
|
|
||||||
} @else if (!runs.length) {
|
|
||||||
<div class="cic-empty">No fetch has run yet.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Run</th>
|
|
||||||
<th>Bureau</th>
|
|
||||||
<th>How</th>
|
|
||||||
<th>Window</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th class="num">Received</th>
|
|
||||||
<th class="num">New</th>
|
|
||||||
<th class="num">Matched</th>
|
|
||||||
<th class="num">Unmatched</th>
|
|
||||||
<th class="num">Already had</th>
|
|
||||||
<th class="num">Own bank</th>
|
|
||||||
<th>By</th>
|
|
||||||
<th>When</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (r of runs; track r.id) {
|
|
||||||
<tr class="clickable" (click)="view(r)">
|
|
||||||
<td class="cic-mono">{{ r.runRef }}</td>
|
|
||||||
<td>{{ r.bureauCode }}</td>
|
|
||||||
<td>{{ r.trigger }}</td>
|
|
||||||
<td>{{ r.fromDate | date: 'dd MMM' }} – {{ r.toDate | date: 'dd MMM yy' }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class]="'cic-badge ' + tone(r.status)">{{ r.status }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ r.received }}</td>
|
|
||||||
<td class="num">
|
|
||||||
<b>{{ r.newAlerts }}</b>
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ r.matched }}</td>
|
|
||||||
<td class="num">{{ r.unmatched }}</td>
|
|
||||||
<td class="num">{{ r.duplicates }}</td>
|
|
||||||
<td class="num">{{ r.ownBank }}</td>
|
|
||||||
<td>{{ r.triggeredByName }}</td>
|
|
||||||
<td>{{ r.startedOn | slice: 0 : 16 }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="cic-pager">
|
|
||||||
<span>{{ offset + 1 }}–{{ offset + runs.length }} of {{ total }}</span>
|
|
||||||
<span class="cic-actions">
|
|
||||||
<button class="cic-btn sm" (click)="page(-1)" [disabled]="offset === 0">Previous</button>
|
|
||||||
<button class="cic-btn sm" (click)="page(1)" [disabled]="offset + limit >= total">Next</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (viewing) {
|
|
||||||
<div class="cic-modal-backdrop" (click)="viewing = null">
|
|
||||||
<div class="cic-modal" style="width: min(1000px, 100%)" (click)="$event.stopPropagation()">
|
|
||||||
<h3>{{ viewing.runRef }} · {{ viewing.bureauCode }} · {{ viewing.status }}</h3>
|
|
||||||
<div
|
|
||||||
class="cic-alert"
|
|
||||||
[class.success]="viewing.status === 'SUCCESS'"
|
|
||||||
[class.error]="viewing.status !== 'SUCCESS'"
|
|
||||||
>
|
|
||||||
{{ viewing.message }}
|
|
||||||
@if (viewing.httpStatus) {
|
|
||||||
(HTTP {{ viewing.httpStatus }}, {{ viewing.durationMs }} ms)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="cic-split">
|
|
||||||
<div>
|
|
||||||
<h4 style="margin: 0 0 6px; font-size: 13px">Request</h4>
|
|
||||||
<pre class="cic-code">{{ pretty(viewing.request) || '—' }}</pre>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 style="margin: 0 0 6px; font-size: 13px">Response</h4>
|
|
||||||
<pre class="cic-code">{{ pretty(viewing.response) || '—' }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="foot">
|
|
||||||
<button class="cic-btn" (click)="viewing = null">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { CommonModule } from '@angular/common';
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { CicBureau, CicFetchRun, CicService, cicError } from '../cic.service';
|
|
||||||
|
|
||||||
/** Log of every alert fetch from the CICs: when, how many alerts, how many matched, and the request / response. */
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-cic-fetch-log',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
RouterModule
|
|
||||||
],
|
|
||||||
templateUrl: './cic-fetch-log.component.html',
|
|
||||||
styleUrls: ['../cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class CicFetchLogComponent implements OnInit {
|
|
||||||
private cic = inject(CicService);
|
|
||||||
|
|
||||||
bureaus: CicBureau[] = [];
|
|
||||||
runs: CicFetchRun[] = [];
|
|
||||||
total = 0;
|
|
||||||
offset = 0;
|
|
||||||
readonly limit = 20;
|
|
||||||
bureauId = '';
|
|
||||||
viewing: CicFetchRun | null = null;
|
|
||||||
loading = false;
|
|
||||||
error: string | null = null;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.cic.bureaus().subscribe({ next: (b) => (this.bureaus = b), error: () => (this.bureaus = []) });
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.error = null;
|
|
||||||
this.cic.fetchRuns({ bureauId: this.bureauId, offset: this.offset, limit: this.limit }).subscribe({
|
|
||||||
next: (p) => {
|
|
||||||
this.runs = p.pageItems;
|
|
||||||
this.total = p.totalFilteredRecords;
|
|
||||||
this.loading = false;
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.error = cicError(e, 'Could not load the fetch log.');
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
filterChanged(): void {
|
|
||||||
this.offset = 0;
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
page(delta: number): void {
|
|
||||||
this.offset = Math.max(0, this.offset + delta * this.limit);
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
view(r: CicFetchRun): void {
|
|
||||||
this.cic.fetchRun(r.id).subscribe({
|
|
||||||
next: (full) => (this.viewing = full),
|
|
||||||
error: (e) => (this.error = cicError(e))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pretty(text?: string | null): string {
|
|
||||||
if (!text) return '';
|
|
||||||
try {
|
|
||||||
return JSON.stringify(JSON.parse(text), null, 2);
|
|
||||||
} catch {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tone(status: string): string {
|
|
||||||
return status === 'SUCCESS' ? 'good' : status === 'FAILED' ? 'bad' : 'warn';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,627 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* Shared look for the Credit Bureau (CIC) screens. */
|
|
||||||
|
|
||||||
:host {
|
|
||||||
--cic-ink: #0f172a;
|
|
||||||
--cic-muted: #64748b;
|
|
||||||
--cic-line: #e2e8f0;
|
|
||||||
--cic-soft: #f8fafc;
|
|
||||||
--cic-brand: #1d4ed8;
|
|
||||||
--cic-brand-soft: #eff6ff;
|
|
||||||
--cic-good: #047857;
|
|
||||||
--cic-good-soft: #ecfdf5;
|
|
||||||
--cic-ok: #0369a1;
|
|
||||||
--cic-ok-soft: #f0f9ff;
|
|
||||||
--cic-warn: #b45309;
|
|
||||||
--cic-warn-soft: #fffbeb;
|
|
||||||
--cic-bad: #b91c1c;
|
|
||||||
--cic-bad-soft: #fef2f2;
|
|
||||||
|
|
||||||
display: block;
|
|
||||||
color: var(--cic-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-page {
|
|
||||||
padding: 20px 24px 40px;
|
|
||||||
max-width: 1440px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
color: var(--cic-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--cic-line);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 18px 20px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
|
||||||
|
|
||||||
> h2,
|
|
||||||
.cic-card-title {
|
|
||||||
margin: 0 0 14px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-card-sub {
|
|
||||||
color: var(--cic-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
|
|
||||||
&.cols-2 {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
&.cols-3 {
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
&.cols-4 {
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width <= 960px) {
|
|
||||||
&.cols-3,
|
|
||||||
&.cols-4 {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (width <= 640px) {
|
|
||||||
&.cols-2,
|
|
||||||
&.cols-3,
|
|
||||||
&.cols-4 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-split {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
|
||||||
gap: 16px;
|
|
||||||
|
|
||||||
@media (width <= 1100px) {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-stat {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--cic-line);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
|
|
||||||
.k {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--cic-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-top: 6px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.s {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--cic-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--cic-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
background: #fff;
|
|
||||||
color: var(--cic-ink);
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--cic-brand);
|
|
||||||
box-shadow: 0 0 0 3px rgb(29 78 216 / 12%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background: var(--cic-soft);
|
|
||||||
color: var(--cic-muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 90px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-mono,
|
|
||||||
textarea.cic-mono {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace !important;
|
|
||||||
font-size: 12px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
border: 1px solid var(--cic-line);
|
|
||||||
background: #fff;
|
|
||||||
color: var(--cic-ink);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: var(--cic-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.primary {
|
|
||||||
background: var(--cic-brand);
|
|
||||||
border-color: var(--cic-brand);
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: #1e40af;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.danger {
|
|
||||||
color: var(--cic-bad);
|
|
||||||
border-color: #fecaca;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: var(--cic-bad-soft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.sm {
|
|
||||||
padding: 5px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 9px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
background: var(--cic-soft);
|
|
||||||
color: var(--cic-muted);
|
|
||||||
border: 1px solid var(--cic-line);
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&.good {
|
|
||||||
background: var(--cic-good-soft);
|
|
||||||
color: var(--cic-good);
|
|
||||||
border-color: #a7f3d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ok {
|
|
||||||
background: var(--cic-ok-soft);
|
|
||||||
color: var(--cic-ok);
|
|
||||||
border-color: #bae6fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.warn {
|
|
||||||
background: var(--cic-warn-soft);
|
|
||||||
color: var(--cic-warn);
|
|
||||||
border-color: #fde68a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.bad {
|
|
||||||
background: var(--cic-bad-soft);
|
|
||||||
color: var(--cic-bad);
|
|
||||||
border-color: #fecaca;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.brand {
|
|
||||||
background: var(--cic-brand-soft);
|
|
||||||
color: var(--cic-brand);
|
|
||||||
border-color: #bfdbfe;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-alert {
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
border: 1px solid;
|
|
||||||
|
|
||||||
&.error {
|
|
||||||
background: var(--cic-bad-soft);
|
|
||||||
border-color: #fecaca;
|
|
||||||
color: var(--cic-bad);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
background: var(--cic-good-soft);
|
|
||||||
border-color: #a7f3d0;
|
|
||||||
color: var(--cic-good);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.info {
|
|
||||||
background: var(--cic-brand-soft);
|
|
||||||
border-color: #bfdbfe;
|
|
||||||
color: #1e3a8a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.warn {
|
|
||||||
background: var(--cic-warn-soft);
|
|
||||||
border-color: #fde68a;
|
|
||||||
color: var(--cic-warn);
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
padding-left: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
border: 1px solid var(--cic-line);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
th {
|
|
||||||
text-align: left;
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--cic-muted);
|
|
||||||
font-weight: 700;
|
|
||||||
background: var(--cic-soft);
|
|
||||||
padding: 9px 12px;
|
|
||||||
border-bottom: 1px solid var(--cic-line);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 9px 12px;
|
|
||||||
border-bottom: 1px solid #f1f5f9;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr.clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover td {
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.num {
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
td input,
|
|
||||||
td select {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 5px 7px;
|
|
||||||
font-size: 12px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-empty {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--cic-muted);
|
|
||||||
padding: 28px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-pager {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--cic-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-switch {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 40px;
|
|
||||||
height: 22px;
|
|
||||||
flex: none;
|
|
||||||
|
|
||||||
input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: #cbd5e1;
|
|
||||||
border-radius: 999px;
|
|
||||||
transition: background 0.15s;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
left: 3px;
|
|
||||||
top: 3px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: transform 0.15s;
|
|
||||||
box-shadow: 0 1px 2px rgb(0 0 0 / 20%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + span {
|
|
||||||
background: var(--cic-good);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + span::before {
|
|
||||||
transform: translateX(18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:disabled + span {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
border-bottom: 1px solid var(--cic-line);
|
|
||||||
margin-bottom: 14px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
padding: 9px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--cic-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: var(--cic-brand);
|
|
||||||
border-bottom-color: var(--cic-brand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-kv {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 170px minmax(0, 1fr);
|
|
||||||
gap: 7px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
dt {
|
|
||||||
color: var(--cic-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-code {
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
max-height: 420px;
|
|
||||||
overflow: auto;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-trend {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
height: 180px;
|
|
||||||
padding-top: 6px;
|
|
||||||
|
|
||||||
.col {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 28px;
|
|
||||||
min-height: 2px;
|
|
||||||
background: var(--cic-brand);
|
|
||||||
border-radius: 4px 4px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.n {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--cic-muted);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.d {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--cic-muted);
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgb(15 23 42 / 45%);
|
|
||||||
z-index: 1000;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-modal {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 14px;
|
|
||||||
width: min(640px, 100%);
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 20px 22px;
|
|
||||||
box-shadow: 0 20px 50px rgb(15 23 42 / 25%);
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.foot {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cic-bar {
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
> div {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--cic-brand);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
|
||||||
color: var(--cic-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tone-good {
|
|
||||||
color: var(--cic-good);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tone-ok {
|
|
||||||
color: var(--cic-ok);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tone-warn {
|
|
||||||
color: var(--cic-warn);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tone-bad {
|
|
||||||
color: var(--cic-bad);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,276 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
|
||||||
import { Injectable, inject } from '@angular/core';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
|
|
||||||
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
|
||||||
|
|
||||||
/** A credit bureau (CIC) as configured for the bank. Never carries the secret itself. */
|
|
||||||
export interface CicBureau {
|
|
||||||
id: number;
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
whitelisted: boolean;
|
|
||||||
isDefault: boolean;
|
|
||||||
environment: 'SIMULATOR' | 'UAT' | 'PRODUCTION';
|
|
||||||
baseUrl?: string;
|
|
||||||
memberCode?: string;
|
|
||||||
authType: string;
|
|
||||||
username?: string;
|
|
||||||
apiKeyHeader?: string;
|
|
||||||
hasSecret: boolean;
|
|
||||||
tokenUrl?: string;
|
|
||||||
clientId?: string;
|
|
||||||
timeoutMs: number;
|
|
||||||
alertAutoFetch: boolean;
|
|
||||||
alertFetchIntervalMin: number;
|
|
||||||
alertLookbackDays: number;
|
|
||||||
lastFetchOn?: string;
|
|
||||||
lastFetchStatus?: string;
|
|
||||||
lastFetchMessage?: string;
|
|
||||||
lastTestStatus?: string;
|
|
||||||
lastTestMessage?: string;
|
|
||||||
lastTestedOn?: string;
|
|
||||||
updatedOn?: string;
|
|
||||||
activeEndpoints: number;
|
|
||||||
alertsLast30Days: number;
|
|
||||||
newAlerts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CicMapping {
|
|
||||||
id?: number;
|
|
||||||
sourceField: string | null;
|
|
||||||
targetField: string;
|
|
||||||
transform: string;
|
|
||||||
defaultValue: string | null;
|
|
||||||
mandatory: boolean;
|
|
||||||
sortOrder?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CicEndpoint {
|
|
||||||
bureauId: number;
|
|
||||||
endpointType: string;
|
|
||||||
exists: boolean;
|
|
||||||
id?: number;
|
|
||||||
httpMethod: string;
|
|
||||||
path?: string;
|
|
||||||
contentType: string;
|
|
||||||
headersJson: string;
|
|
||||||
baseBody: string;
|
|
||||||
isActive: boolean;
|
|
||||||
requestMappings: CicMapping[];
|
|
||||||
responseMappings: CicMapping[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CicCatalog {
|
|
||||||
environments: string[];
|
|
||||||
authTypes: string[];
|
|
||||||
endpointTypes: string[];
|
|
||||||
httpMethods: string[];
|
|
||||||
cbsFields: Record<string, string>;
|
|
||||||
resultFields: Record<string, string>;
|
|
||||||
rootFields: string[];
|
|
||||||
transforms: Record<string, string>;
|
|
||||||
alertStatuses: string[];
|
|
||||||
matchTypes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One enquiry made on our customer at another lender, as reported by the CIC. */
|
|
||||||
export interface CicAlert {
|
|
||||||
id: number;
|
|
||||||
bureauId: number;
|
|
||||||
bureauCode: string;
|
|
||||||
alertKey: string;
|
|
||||||
clientId?: number;
|
|
||||||
clientName?: string;
|
|
||||||
clientAccountNo?: string;
|
|
||||||
officeId?: number;
|
|
||||||
officeName?: string;
|
|
||||||
matchType: 'PAN' | 'AADHAAR' | 'CUSTOMER_REF' | 'MANUAL' | 'NONE';
|
|
||||||
customerName?: string;
|
|
||||||
panMasked?: string;
|
|
||||||
aadhaarMasked?: string;
|
|
||||||
customerRef?: string;
|
|
||||||
enquiryDate?: string;
|
|
||||||
loanType?: string;
|
|
||||||
amount?: number;
|
|
||||||
purpose?: string;
|
|
||||||
location?: string;
|
|
||||||
status: 'NEW' | 'SEEN' | 'ACTIONED' | 'DISMISSED';
|
|
||||||
remarks?: string;
|
|
||||||
actionedByName?: string;
|
|
||||||
actionedOn?: string;
|
|
||||||
receivedVia?: string;
|
|
||||||
receivedOn?: string;
|
|
||||||
raw?: any;
|
|
||||||
fetchRunId?: number;
|
|
||||||
otherAlertsOfCustomer?: CicAlert[];
|
|
||||||
clientLoans?: {
|
|
||||||
id: number;
|
|
||||||
accountNo: string;
|
|
||||||
productName: string;
|
|
||||||
status: string;
|
|
||||||
principal: number;
|
|
||||||
outstanding: number;
|
|
||||||
disbursedOn?: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CicFetchRun {
|
|
||||||
id: number;
|
|
||||||
runRef: string;
|
|
||||||
bureauId: number;
|
|
||||||
bureauCode: string;
|
|
||||||
trigger: 'MANUAL' | 'SCHEDULED';
|
|
||||||
environment?: string;
|
|
||||||
fromDate?: string;
|
|
||||||
toDate?: string;
|
|
||||||
status: 'RUNNING' | 'SUCCESS' | 'FAILED';
|
|
||||||
httpStatus?: number;
|
|
||||||
received: number;
|
|
||||||
newAlerts: number;
|
|
||||||
matched: number;
|
|
||||||
unmatched: number;
|
|
||||||
duplicates: number;
|
|
||||||
ownBank: number;
|
|
||||||
message?: string;
|
|
||||||
durationMs?: number;
|
|
||||||
triggeredByName?: string;
|
|
||||||
startedOn: string;
|
|
||||||
request?: string;
|
|
||||||
response?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CicPage<T> {
|
|
||||||
totalFilteredRecords: number;
|
|
||||||
pageItems: T[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CIC_MATCH_LABELS: Record<string, string> = {
|
|
||||||
PAN: 'PAN',
|
|
||||||
AADHAAR: 'Aadhaar',
|
|
||||||
CUSTOMER_REF: 'Customer ref',
|
|
||||||
MANUAL: 'Linked manually',
|
|
||||||
NONE: 'Not matched'
|
|
||||||
};
|
|
||||||
|
|
||||||
export function cicStatusTone(status: string): string {
|
|
||||||
return status === 'NEW' ? 'bad' : status === 'SEEN' ? 'warn' : status === 'ACTIONED' ? 'good' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The server's own message for a failed call. */
|
|
||||||
export function cicError(err: any, fallback = 'Something went wrong. Please try again.'): string {
|
|
||||||
return (
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
|
||||||
err?.error?.defaultUserMessage ??
|
|
||||||
err?.error?.message ??
|
|
||||||
err?.message ??
|
|
||||||
fallback
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class CicService {
|
|
||||||
private http = inject(HttpClient);
|
|
||||||
private base = '/cic';
|
|
||||||
/** Errors are shown inline on these screens, so the global alert is suppressed. */
|
|
||||||
private silent = { context: new HttpContext().set(SILENT_HTTP_ERROR, true) };
|
|
||||||
|
|
||||||
catalog(): Observable<CicCatalog> {
|
|
||||||
return this.http.get<CicCatalog>(`${this.base}/catalog`, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
bureaus(whitelistedOnly = false): Observable<CicBureau[]> {
|
|
||||||
return this.http.get<CicBureau[]>(`${this.base}/bureaus`, {
|
|
||||||
...this.silent,
|
|
||||||
params: new HttpParams().set('whitelistedOnly', String(whitelistedOnly))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBureau(id: number, body: any): Observable<CicBureau> {
|
|
||||||
return this.http.put<CicBureau>(`${this.base}/bureaus/${id}`, body, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
setWhitelisted(id: number, whitelisted: boolean): Observable<CicBureau> {
|
|
||||||
return this.http.put<CicBureau>(`${this.base}/bureaus/${id}/whitelist`, { whitelisted }, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
testConnection(id: number): Observable<{ status: string; message: string }> {
|
|
||||||
return this.http.post<{ status: string; message: string }>(
|
|
||||||
`${this.base}/bureaus/${id}/test-connection`,
|
|
||||||
{},
|
|
||||||
this.silent
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint(bureauId: number, type: string): Observable<CicEndpoint> {
|
|
||||||
return this.http.get<CicEndpoint>(`${this.base}/bureaus/${bureauId}/endpoints/${type}`, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveEndpoint(bureauId: number, type: string, body: any): Observable<CicEndpoint> {
|
|
||||||
return this.http.put<CicEndpoint>(`${this.base}/bureaus/${bureauId}/endpoints/${type}`, body, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
preview(bureauId: number, body: { fromDate?: string; toDate?: string }): Observable<any> {
|
|
||||||
return this.http.post<any>(`${this.base}/bureaus/${bureauId}/preview`, body, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
parseTest(bureauId: number, content: string | null): Observable<any> {
|
|
||||||
return this.http.post<any>(`${this.base}/bureaus/${bureauId}/parse-test`, { content }, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(bureauId: number, body: { fromDate?: string; toDate?: string } = {}): Observable<CicFetchRun> {
|
|
||||||
return this.http.post<CicFetchRun>(`${this.base}/bureaus/${bureauId}/fetch`, body, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRuns(filter: Record<string, any>): Observable<CicPage<CicFetchRun>> {
|
|
||||||
return this.http.get<CicPage<CicFetchRun>>(`${this.base}/fetch-runs`, {
|
|
||||||
...this.silent,
|
|
||||||
params: this.params(filter)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRun(id: number): Observable<CicFetchRun> {
|
|
||||||
return this.http.get<CicFetchRun>(`${this.base}/fetch-runs/${id}`, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
dashboard(days = 30): Observable<any> {
|
|
||||||
return this.http.get<any>(`${this.base}/dashboard`, {
|
|
||||||
...this.silent,
|
|
||||||
params: new HttpParams().set('days', String(days))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
alert(id: number): Observable<CicAlert> {
|
|
||||||
return this.http.get<CicAlert>(`${this.base}/alerts/${id}`, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatus(id: number, status: string, remarks?: string | null): Observable<CicAlert> {
|
|
||||||
return this.http.put<CicAlert>(`${this.base}/alerts/${id}/status`, { status, remarks }, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
linkClient(id: number, clientId: number): Observable<CicAlert> {
|
|
||||||
return this.http.put<CicAlert>(`${this.base}/alerts/${id}/client`, { clientId }, this.silent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private params(filter: Record<string, any>): HttpParams {
|
|
||||||
let params = new HttpParams();
|
|
||||||
Object.entries(filter).forEach(
|
|
||||||
([
|
|
||||||
k,
|
|
||||||
v
|
|
||||||
]) => {
|
|
||||||
if (v !== null && v !== undefined && v !== '') params = params.set(k, String(v));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { NgModule } from '@angular/core';
|
|
||||||
import { Routes, RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { Route } from '../core/route/route.service';
|
|
||||||
import { CicDashboardComponent } from './cic-dashboard/cic-dashboard.component';
|
|
||||||
import { CicFetchLogComponent } from './cic-fetch-log/cic-fetch-log.component';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
|
||||||
Route.withShell([
|
|
||||||
{
|
|
||||||
path: 'credit-bureau',
|
|
||||||
data: { title: 'Credit Bureau', breadcrumb: 'Credit Bureau' },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
redirectTo: 'dashboard',
|
|
||||||
pathMatch: 'full'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'dashboard',
|
|
||||||
component: CicDashboardComponent,
|
|
||||||
data: { title: 'Credit Bureau Dashboard', breadcrumb: 'Dashboard' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'fetch-log',
|
|
||||||
component: CicFetchLogComponent,
|
|
||||||
data: { title: 'Fetch Log', breadcrumb: 'Fetch Log' }
|
|
||||||
},
|
|
||||||
// The bureaus are set up, whitelisted and mapped under API Management like every other vendor.
|
|
||||||
{ path: 'bureaus', redirectTo: '/api-management/vendors', pathMatch: 'full' },
|
|
||||||
{ path: 'api-mapping', redirectTo: '/api-management/vendors', pathMatch: 'full' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
])
|
|
||||||
];
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [RouterModule.forChild(routes)],
|
|
||||||
exports: [RouterModule]
|
|
||||||
})
|
|
||||||
export class CreditBureauRoutingModule {}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { NgModule } from '@angular/core';
|
|
||||||
|
|
||||||
import { SharedModule } from '../shared/shared.module';
|
|
||||||
import { CreditBureauRoutingModule } from './credit-bureau-routing.module';
|
|
||||||
import { CicDashboardComponent } from './cic-dashboard/cic-dashboard.component';
|
|
||||||
import { CicFetchLogComponent } from './cic-fetch-log/cic-fetch-log.component';
|
|
||||||
import { CicAlertDetailComponent } from './cic-alert-detail/cic-alert-detail.component';
|
|
||||||
import { CicService } from './cic.service';
|
|
||||||
import { CicStaticService } from './cic-static.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CIC / credit bureau (CIBIL, Experian, Equifax, CRIF) - its own top-level menu: enquiry alerts on our customers from
|
|
||||||
* other lenders and the fetch log. Each bureau's connection, whitelist and API mapping live under API Management.
|
|
||||||
*/
|
|
||||||
@NgModule({
|
|
||||||
imports: [
|
|
||||||
SharedModule,
|
|
||||||
CreditBureauRoutingModule,
|
|
||||||
CicDashboardComponent,
|
|
||||||
CicFetchLogComponent,
|
|
||||||
CicAlertDetailComponent
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
// Static demo data, no backend call. Remove this line to use the real /v1/cic API again.
|
|
||||||
{ provide: CicService, useClass: CicStaticService }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class CreditBureauModule {}
|
|
||||||
+3
-29
@@ -13,18 +13,6 @@
|
|||||||
<i class="fas fa-info-circle"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
<h3>Account Details</h3>
|
<h3>Account Details</h3>
|
||||||
</div>
|
</div>
|
||||||
@if (isCcRequest) {
|
|
||||||
<p class="sad-cc-note">
|
|
||||||
@if (ccProductsMissing) {
|
|
||||||
<strong>No CC product yet.</strong> A Cash Credit account is opened on a current product that allows an
|
|
||||||
overdraft against a sanctioned limit. Create or edit a current product with “Is Overdraft Allowed” switched
|
|
||||||
on, then come back here.
|
|
||||||
} @else {
|
|
||||||
Opening a <strong>Cash Credit (CC)</strong> account: only products that allow an overdraft are listed. The
|
|
||||||
sanctioned limit comes from the product.
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
<div class="sad-grid">
|
<div class="sad-grid">
|
||||||
<mat-form-field appearance="outline" class="sad-field">
|
<mat-form-field appearance="outline" class="sad-field">
|
||||||
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
||||||
@@ -850,23 +838,9 @@
|
|||||||
<span class="ni-mono">{{ c.clientCif || '—' }}</span>
|
<span class="ni-mono">{{ c.clientCif || '—' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="ni-td ni-td--center">
|
<td class="ni-td ni-td--center">
|
||||||
@if (readOnly) {
|
<span class="ni-pct-badge" [class.ni-pct-badge--full]="c.sharePercentage === 100">
|
||||||
<span class="ni-pct-badge" [class.ni-pct-badge--full]="c.sharePercentage === 100">
|
{{ c.sharePercentage }}%
|
||||||
{{ c.sharePercentage }}%
|
</span>
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
<span class="ni-pct-input">
|
|
||||||
<input
|
|
||||||
#pct
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
[value]="c.sharePercentage"
|
|
||||||
(change)="setPartnerShare(c, pct.value); pct.value = '' + c.sharePercentage"
|
|
||||||
aria-label="Ownership percentage"
|
|
||||||
/>%
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="ni-td ni-td--center">
|
<td class="ni-td ni-td--center">
|
||||||
<button
|
<button
|
||||||
|
|||||||
-38
@@ -465,31 +465,6 @@ $ni-bg: #f9fafb;
|
|||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ni-pct-input {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: $ni-primary;
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 52px;
|
|
||||||
padding: 3px 6px;
|
|
||||||
border: 1px solid #c7d2fe;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #eef2ff;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
text-align: right;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: 2px solid $ni-primary;
|
|
||||||
outline-offset: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ni-pct-badge {
|
.ni-pct-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1735,16 +1710,3 @@ $ni-bg: #f9fafb;
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shown when the screen was opened from "New CC Account". */
|
|
||||||
.sad-cc-note {
|
|
||||||
margin: 0 0 14px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid #bfdbfe;
|
|
||||||
border-left: 4px solid #1d4ed8;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1e3a8a;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|||||||
-83
@@ -29,10 +29,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
import { IntroducerSearchComponent } from 'app/shared/introducer-search/introducer-search.component';
|
import { IntroducerSearchComponent } from 'app/shared/introducer-search/introducer-search.component';
|
||||||
import { AddRelatedClientDialogComponent } from './add-related-client-dialog/add-related-client-dialog.component';
|
import { AddRelatedClientDialogComponent } from './add-related-client-dialog/add-related-client-dialog.component';
|
||||||
import { AlertService } from 'app/core/alert/alert.service';
|
import { AlertService } from 'app/core/alert/alert.service';
|
||||||
import { forkJoin, of } from 'rxjs';
|
|
||||||
import { catchError } from 'rxjs/operators';
|
|
||||||
import { ActivatedRoute } from '@angular/router';
|
|
||||||
import { ProductsService } from 'app/products/products.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current Account Details Step
|
* Current Account Details Step
|
||||||
@@ -60,13 +56,6 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
|||||||
private alertService = inject(AlertService);
|
private alertService = inject(AlertService);
|
||||||
private dateUtils = inject(Dates);
|
private dateUtils = inject(Dates);
|
||||||
private cdr = inject(ChangeDetectorRef);
|
private cdr = inject(ChangeDetectorRef);
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
private productsService = inject(ProductsService);
|
|
||||||
|
|
||||||
/** Opened from "New CC Account": only products that allow an overdraft are offered. */
|
|
||||||
isCcRequest = false;
|
|
||||||
/** A CC account was asked for but no current product allows an overdraft yet. */
|
|
||||||
ccProductsMissing = false;
|
|
||||||
|
|
||||||
@Input() currentAccountTemplate: any;
|
@Input() currentAccountTemplate: any;
|
||||||
@Input() hideButtons = false;
|
@Input() hideButtons = false;
|
||||||
@@ -218,20 +207,12 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
|||||||
this.autoDetectAccountStatus();
|
this.autoDetectAccountStatus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.isNonIndividualClient && !this.isEditMode && !this.readOnly) {
|
|
||||||
this.prefillPartnersFromClientSignatories(client);
|
|
||||||
}
|
|
||||||
this.cdr.detectChanges();
|
this.cdr.detectChanges();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isCcRequest = this.route.snapshot.queryParamMap.get('cc') === '1';
|
|
||||||
|
|
||||||
if (this.currentAccountTemplate) {
|
if (this.currentAccountTemplate) {
|
||||||
this.productData = this.currentAccountTemplate.productOptions;
|
this.productData = this.currentAccountTemplate.productOptions;
|
||||||
if (this.isCcRequest && !this.currentAccountTemplate.accountNo) {
|
|
||||||
this.limitProductsToCc();
|
|
||||||
}
|
|
||||||
const existingProductId =
|
const existingProductId =
|
||||||
this.currentAccountTemplate.currentAccountProductId ?? this.currentAccountTemplate.productId;
|
this.currentAccountTemplate.currentAccountProductId ?? this.currentAccountTemplate.productId;
|
||||||
// Edit mode: account response always has accountNo; pure template has previewAccountNo only
|
// Edit mode: account response always has accountNo; pure template has previewAccountNo only
|
||||||
@@ -269,24 +250,6 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A Cash Credit account takes its overdraft and limit from the product it is opened on, so a CC account is opened on a
|
|
||||||
* product that allows an overdraft. The template lists every current product without saying which allow one, so the
|
|
||||||
* product list is read to tell them apart.
|
|
||||||
*/
|
|
||||||
private limitProductsToCc(): void {
|
|
||||||
this.productsService
|
|
||||||
.getCurrentProducts()
|
|
||||||
.pipe(catchError(() => of([] as any[])))
|
|
||||||
.subscribe((res: any) => {
|
|
||||||
const products: any[] = Array.isArray(res) ? res : (res?.pageItems ?? []);
|
|
||||||
const overdraftIds = new Set(products.filter((p) => p.allowOverdraft).map((p) => p.id));
|
|
||||||
this.productData = (this.productData || []).filter((p: any) => overdraftIds.has(p.id));
|
|
||||||
this.ccProductsMissing = this.productData.length === 0;
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createCurrentAccountDetailsForm() {
|
createCurrentAccountDetailsForm() {
|
||||||
this.currentAccountDetailsForm = this.formBuilder.group({
|
this.currentAccountDetailsForm = this.formBuilder.group({
|
||||||
productId: [
|
productId: [
|
||||||
@@ -659,52 +622,6 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Non-Individual (existing) helpers ──────────────────────────────────
|
// ── Non-Individual (existing) helpers ──────────────────────────────────
|
||||||
/**
|
|
||||||
* A new entity account starts with the authorised signatories recorded on the client at creation
|
|
||||||
* (clientNonPersonDetails.authorizedSignatoryClientIds), so they need not be added again. Ownership is
|
|
||||||
* not captured at client creation, so it starts at 0 and is entered in the table; Add Partner stays
|
|
||||||
* available for anyone else.
|
|
||||||
*/
|
|
||||||
private prefillPartnersFromClientSignatories(client: any): void {
|
|
||||||
if (this.relatedClients.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ids: number[] = String(client?.clientNonPersonDetails?.authorizedSignatoryClientIds ?? '')
|
|
||||||
.split(',')
|
|
||||||
.map((id) => Number(id.trim()))
|
|
||||||
.filter((id, i, all) => id > 0 && id !== this.primaryClientId && all.indexOf(id) === i);
|
|
||||||
if (!ids.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// One lookup per signatory for the CIF; a signatory that can no longer be read is skipped, not fatal.
|
|
||||||
forkJoin(
|
|
||||||
ids.map((id) => this.clientsService.getClientData(String(id), true).pipe(catchError(() => of(null))))
|
|
||||||
).subscribe((signatories: any[]) => {
|
|
||||||
// The user may have added partners by hand while the lookups ran — never overwrite those.
|
|
||||||
if (this.relatedClients.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.relatedClients = signatories
|
|
||||||
.filter((s) => !!s?.id)
|
|
||||||
.map((s) => ({
|
|
||||||
clientId: s.id,
|
|
||||||
clientName: s.displayName ?? s.fullname ?? '',
|
|
||||||
clientCif: s.cifNumber ?? s.cifNo ?? '',
|
|
||||||
sharePercentage: 0,
|
|
||||||
isMandatory: false
|
|
||||||
}));
|
|
||||||
this.autoSetSignatoryRights();
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inline ownership edit — clamped so the partners' total never exceeds 100%. */
|
|
||||||
setPartnerShare(client: any, raw: any): void {
|
|
||||||
const othersTotal = this.totalSharePercentage - (client.sharePercentage || 0);
|
|
||||||
const value = Math.round(Number(raw) || 0);
|
|
||||||
client.sharePercentage = Math.min(Math.max(value, 0), Math.max(0, 100 - othersTotal));
|
|
||||||
}
|
|
||||||
|
|
||||||
openAddRelatedClientDialog(): void {
|
openAddRelatedClientDialog(): void {
|
||||||
const excludedClientIds = [
|
const excludedClientIds = [
|
||||||
...(this.primaryClientId ? [this.primaryClientId] : []),
|
...(this.primaryClientId ? [this.primaryClientId] : []),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Directive, HostListener, Input, ElementRef, inject } from '@angular/cor
|
|||||||
import { NgControl } from '@angular/forms';
|
import { NgControl } from '@angular/forms';
|
||||||
|
|
||||||
/** The kinds of field this knows how to hold to a shape. */
|
/** The kinds of field this knows how to hold to a shape. */
|
||||||
export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar' | 'gstin' | 'tan';
|
export type RestrictKind = 'digits' | 'phone' | 'pan' | 'aadhaar';
|
||||||
|
|
||||||
interface Rule {
|
interface Rule {
|
||||||
/** Characters this kind will accept at all. */
|
/** Characters this kind will accept at all. */
|
||||||
@@ -27,10 +27,7 @@ const RULES: Record<RestrictKind, Rule> = {
|
|||||||
// A PAN is five letters, four digits, one letter - and is always written in capitals.
|
// A PAN is five letters, four digits, one letter - and is always written in capitals.
|
||||||
pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true },
|
pan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true },
|
||||||
// Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits.
|
// Twelve digits, or the masked form CKYC returns, which is Xs and the last four digits.
|
||||||
aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true },
|
aadhaar: { allowed: /[0-9Xx]/, maxLength: 12, upper: true }
|
||||||
// GSTIN is fifteen letters and digits, TAN ten; both are always written in capitals.
|
|
||||||
gstin: { allowed: /[A-Za-z0-9]/, maxLength: 15, upper: true },
|
|
||||||
tan: { allowed: /[A-Za-z0-9]/, maxLength: 10, upper: true }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ export interface CtrTransaction {
|
|||||||
declaration: string;
|
declaration: string;
|
||||||
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
|
/** Customer name resolved from KC1 (UI convenience, not in actual TC2 schema) */
|
||||||
customerName?: string;
|
customerName?: string;
|
||||||
/** True for a transaction that has crossed the threshold but is not yet in a generated CTR. */
|
|
||||||
pendingFiling?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* CTR does not judge suspicion. It records only whether the source of funds was
|
* CTR does not judge suspicion. It records only whether the source of funds was
|
||||||
* evidenced at the counter (CTR filed, nothing further) or could not be, in which
|
* evidenced at the counter (CTR filed, nothing further) or could not be, in which
|
||||||
|
|||||||
+141
-233
@@ -223,15 +223,6 @@
|
|||||||
<div class="fiu-note">Loading the generated CTR lines…</div>
|
<div class="fiu-note">Loading the generated CTR lines…</div>
|
||||||
} @else if (ctrLinesError) {
|
} @else if (ctrLinesError) {
|
||||||
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
|
<div class="fiu-note fiu-note--warn"><fa-icon icon="exclamation-triangle"></fa-icon> {{ ctrLinesError }}</div>
|
||||||
} @else if (ctrPendingCount > 0) {
|
|
||||||
<div class="fiu-note">
|
|
||||||
<fa-icon icon="info-circle"></fa-icon>
|
|
||||||
{{ ctrPendingCount }} cash transaction{{ ctrPendingCount > 1 ? 's' : '' }} of ₹10,00,000 or more
|
|
||||||
{{ ctrPendingCount > 1 ? 'are' : 'is' }} listed below automatically as soon as
|
|
||||||
{{ ctrPendingCount > 1 ? 'they are' : 'it is' }} posted.
|
|
||||||
{{ ctrPendingCount > 1 ? 'They are' : 'It is' }} not in a filed report yet — pick the month under “Generate
|
|
||||||
CTR” and run it to include {{ ctrPendingCount > 1 ? 'them' : 'it' }}.
|
|
||||||
</div>
|
|
||||||
} @else if (!ctrTxns.length) {
|
} @else if (!ctrTxns.length) {
|
||||||
<div class="fiu-note fiu-note--warn">
|
<div class="fiu-note fiu-note--warn">
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
@@ -351,103 +342,27 @@
|
|||||||
<th class="ta-c">Records</th>
|
<th class="ta-c">Records</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Submitted On</th>
|
<th>Submitted On</th>
|
||||||
<th class="ta-c">Customers</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (r of amlCtrReports; track r.id) {
|
@for (r of amlCtrReports; track r.id) {
|
||||||
<tr style="cursor: pointer" (click)="openCtrReport(r)">
|
<tr>
|
||||||
<td>{{ r.reportMonth }}</td>
|
<td>{{ r.reportMonth }}</td>
|
||||||
<td>{{ r.generatedOn ? (asDate(r.generatedOn) | date: 'medium') : '—' }}</td>
|
<td>{{ r.generatedOn | date: 'medium' }}</td>
|
||||||
<td class="ta-c">{{ r.recordCount }}</td>
|
<td class="ta-c">{{ r.recordCount }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
|
<span [class]="amlStatusClass(r.status)">{{ r.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ r.submittedOn ? (asDate(r.submittedOn) | date: 'medium') : '—' }}</td>
|
<td>{{ r.submittedOn ? (r.submittedOn | date: 'medium') : '—' }}</td>
|
||||||
<td class="ta-c" (click)="$event.stopPropagation()">
|
|
||||||
<button class="fiu-btn fiu-btn-g" (click)="openCtrReport(r)">View</button>
|
|
||||||
<button class="fiu-btn fiu-btn-g" (click)="exportCtrReport(r)">Excel</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="fiu-empty">No CTR reports generated yet.</td>
|
<td colspan="5" class="fiu-empty">No CTR reports generated yet.</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The customers and transactions one CTR report names. Opened from a row above; declared here so it keeps this
|
|
||||||
component's state and styles. -->
|
|
||||||
<ng-template #ctrReportDialog>
|
|
||||||
<div class="fiu-dialog">
|
|
||||||
@if (ctrReportView; as report) {
|
|
||||||
<div class="fiu-form-meta">
|
|
||||||
<div class="fiu-form-title">
|
|
||||||
<h3>CTR report — {{ report.reportMonth }}</h3>
|
|
||||||
<p>
|
|
||||||
{{ report.status }} · {{ ctrReportLines.length }} record{{ ctrReportLines.length === 1 ? '' : 's' }} ·
|
|
||||||
total ₹{{ ctrReportTotal | number: '1.2-2' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="fiu-action-group">
|
|
||||||
<button
|
|
||||||
class="fiu-btn fiu-btn-xml"
|
|
||||||
[disabled]="ctrReportLinesLoading"
|
|
||||||
(click)="exportCtrReport(report)"
|
|
||||||
>
|
|
||||||
Export Excel
|
|
||||||
</button>
|
|
||||||
<button class="fiu-btn fiu-btn-g" (click)="closeCtrReport()">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@if (ctrReportLinesLoading) {
|
|
||||||
<div class="fiu-note">Loading the customers in this report…</div>
|
|
||||||
} @else {
|
|
||||||
<div class="fiu-tbl-wrap">
|
|
||||||
<table class="fiu-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Customer</th>
|
|
||||||
<th>Customer ID / CKYC</th>
|
|
||||||
<th>Account</th>
|
|
||||||
<th>Branch</th>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Direction</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th class="ta-r">Amount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (l of ctrReportLines; track $index) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{{ l.clientName }}
|
|
||||||
<div style="font-size: 11px; color: var(--f-g500)">
|
|
||||||
{{ l.individual === false ? 'Entity' : 'Individual' }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="mono">{{ l.ckycNumber || l.clientExternalId || '—' }}</td>
|
|
||||||
<td class="mono">{{ l.accountNo || '—' }}</td>
|
|
||||||
<td>{{ l.branchName || '—' }}</td>
|
|
||||||
<td>{{ l.transactionDate ? (asDate(l.transactionDate) | date: 'dd MMM yyyy') : '—' }}</td>
|
|
||||||
<td>{{ l.direction || '—' }}</td>
|
|
||||||
<td>{{ l.connectedGroup ? 'Connected group (month total)' : 'Single transaction' }}</td>
|
|
||||||
<td class="ta-r">₹{{ l.amount | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="8" class="fiu-empty">This report names no customers.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,153 +581,146 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- The case investigation. Opened as a popup by selectStrCase(); declared here so it keeps this component's state. -->
|
@if (selectedStrCase) {
|
||||||
<ng-template #strCaseDialog>
|
<div class="str-case-detail">
|
||||||
<div class="fiu-dialog">
|
<div class="fiu-form-meta">
|
||||||
@if (selectedStrCase) {
|
<div class="fiu-form-title">
|
||||||
<div class="str-case-detail">
|
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
|
||||||
<div class="fiu-form-meta">
|
<p>
|
||||||
<div class="fiu-form-title">
|
Alert #{{ selectedStrCase.alertId }} ·
|
||||||
<h3>STR Case #{{ selectedStrCase.id }} — {{ selectedStrCase.clientDisplayName }}</h3>
|
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
|
||||||
<p>
|
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
|
||||||
Alert #{{ selectedStrCase.alertId }} ·
|
<span class="escalation-badge">Risk category auto-escalated</span>
|
||||||
{{ selectedStrCase.cumulativeStrCaseCountForClient }} cumulative STR case(s) for this client
|
|
||||||
@if (selectedStrCase.cumulativeStrCaseCountForClient >= 3) {
|
|
||||||
<span class="escalation-badge">Risk category auto-escalated</span>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="case-outcome"
|
|
||||||
[class.case-outcome--clear]="selectedStrCase.status === 'CONCLUDED_CLEAR'"
|
|
||||||
[class.case-outcome--suspicious]="
|
|
||||||
selectedStrCase.status === 'CONCLUDED_SUSPICIOUS' || selectedStrCase.status === 'FILED'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<span [class]="amlStatusClass(selectedStrCase.status)">{{ selectedStrCase.status }}</span>
|
|
||||||
<span>{{ strCaseOutcome(selectedStrCase) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="fiuInvestigationNotes"
|
|
||||||
>Investigation notes (source-of-transaction history reviewed)</label
|
|
||||||
>
|
|
||||||
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-row">
|
|
||||||
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
|
|
||||||
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
|
|
||||||
</div>
|
|
||||||
<!-- Verification checklist — the verdict is derived, not chosen. -->
|
|
||||||
<div class="checklist">
|
|
||||||
<div class="checklist-head">
|
|
||||||
<span>Principal Officer verification — mark each point as found</span>
|
|
||||||
<span class="checklist-progress" [class.done]="allAnswered">
|
|
||||||
{{ answeredCount }} / {{ strChecklist.length }} answered
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<table class="checklist-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Verification point</th>
|
|
||||||
<th class="ta-c col-yes">Genuine</th>
|
|
||||||
<th class="ta-c col-no">Not satisfied</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (item of strChecklist; track item.key) {
|
|
||||||
<tr
|
|
||||||
[class.row-yes]="strFindings[item.key] === true"
|
|
||||||
[class.row-no]="strFindings[item.key] === false"
|
|
||||||
[class.row-pending]="strFindings[item.key] === null"
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
<div class="chk-label">{{ item.label }}</div>
|
|
||||||
<div class="chk-hint">{{ item.hint }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="ta-c">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="chk-box chk-box--yes"
|
|
||||||
[class.on]="strFindings[item.key] === true"
|
|
||||||
[attr.aria-pressed]="strFindings[item.key] === true"
|
|
||||||
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
|
|
||||||
(click)="setFinding(item.key, true)"
|
|
||||||
>
|
|
||||||
<fa-icon icon="check-circle"></fa-icon>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td class="ta-c">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="chk-box chk-box--no"
|
|
||||||
[class.on]="strFindings[item.key] === false"
|
|
||||||
[attr.aria-pressed]="strFindings[item.key] === false"
|
|
||||||
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
|
|
||||||
(click)="setFinding(item.key, false)"
|
|
||||||
>
|
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Derived verdict -->
|
|
||||||
<div
|
|
||||||
class="verdict"
|
|
||||||
[class.verdict--clear]="allAnswered && derivedConclusion === 'NOT_SUSPICIOUS'"
|
|
||||||
[class.verdict--suspicious]="allAnswered && derivedConclusion === 'SUSPICIOUS'"
|
|
||||||
>
|
|
||||||
@if (!allAnswered) {
|
|
||||||
<fa-icon icon="info-circle"></fa-icon>
|
|
||||||
<span>
|
|
||||||
Answer every verification point to determine the outcome —
|
|
||||||
{{ strChecklist.length - answeredCount }} still pending.
|
|
||||||
</span>
|
|
||||||
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
|
|
||||||
<fa-icon icon="check-circle"></fa-icon>
|
|
||||||
<span>
|
|
||||||
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case
|
|
||||||
will be closed and nothing filed with FIU-IND.
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
|
||||||
<span>
|
|
||||||
<strong>Suspicious — STR must be filed.</strong>
|
|
||||||
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
|
|
||||||
{{ failedFindings[0].label }}
|
|
||||||
@if (failedFindings.length > 1) {
|
|
||||||
<span> and {{ failedFindings.length - 1 }} more</span>
|
|
||||||
}
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
|
|
||||||
Conclude as
|
|
||||||
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
|
||||||
<div class="file-str-row">
|
|
||||||
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
|
|
||||||
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
|
|
||||||
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
<button class="fiu-btn fiu-btn-g" (click)="closeStrCaseDetail()">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="case-outcome"
|
||||||
|
[class.case-outcome--clear]="selectedStrCase.status === 'CONCLUDED_CLEAR'"
|
||||||
|
[class.case-outcome--suspicious]="
|
||||||
|
selectedStrCase.status === 'CONCLUDED_SUSPICIOUS' || selectedStrCase.status === 'FILED'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span [class]="amlStatusClass(selectedStrCase.status)">{{ selectedStrCase.status }}</span>
|
||||||
|
<span>{{ strCaseOutcome(selectedStrCase) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="case-form" *mifosxHasPermission="'CONCLUDE_AML_STR'">
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="fiuInvestigationNotes">Investigation notes (source-of-transaction history reviewed)</label>
|
||||||
|
<textarea id="fiuInvestigationNotes" rows="3" [(ngModel)]="concludeInvestigationNotes"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="fiuSourceOfFundsNotes">Source-of-funds notes</label>
|
||||||
|
<textarea id="fiuSourceOfFundsNotes" rows="2" [(ngModel)]="concludeSourceOfFundsNotes"></textarea>
|
||||||
|
</div>
|
||||||
|
<!-- Verification checklist — the verdict is derived, not chosen. -->
|
||||||
|
<div class="checklist">
|
||||||
|
<div class="checklist-head">
|
||||||
|
<span>Principal Officer verification — mark each point as found</span>
|
||||||
|
<span class="checklist-progress" [class.done]="allAnswered">
|
||||||
|
{{ answeredCount }} / {{ strChecklist.length }} answered
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="checklist-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Verification point</th>
|
||||||
|
<th class="ta-c col-yes">Genuine</th>
|
||||||
|
<th class="ta-c col-no">Not satisfied</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (item of strChecklist; track item.key) {
|
||||||
|
<tr
|
||||||
|
[class.row-yes]="strFindings[item.key] === true"
|
||||||
|
[class.row-no]="strFindings[item.key] === false"
|
||||||
|
[class.row-pending]="strFindings[item.key] === null"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div class="chk-label">{{ item.label }}</div>
|
||||||
|
<div class="chk-hint">{{ item.hint }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="ta-c">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chk-box chk-box--yes"
|
||||||
|
[class.on]="strFindings[item.key] === true"
|
||||||
|
[attr.aria-pressed]="strFindings[item.key] === true"
|
||||||
|
[attr.aria-label]="'Mark ' + item.label + ' as genuine'"
|
||||||
|
(click)="setFinding(item.key, true)"
|
||||||
|
>
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="ta-c">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chk-box chk-box--no"
|
||||||
|
[class.on]="strFindings[item.key] === false"
|
||||||
|
[attr.aria-pressed]="strFindings[item.key] === false"
|
||||||
|
[attr.aria-label]="'Mark ' + item.label + ' as not satisfied'"
|
||||||
|
(click)="setFinding(item.key, false)"
|
||||||
|
>
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Derived verdict -->
|
||||||
|
<div
|
||||||
|
class="verdict"
|
||||||
|
[class.verdict--clear]="allAnswered && derivedConclusion === 'NOT_SUSPICIOUS'"
|
||||||
|
[class.verdict--suspicious]="allAnswered && derivedConclusion === 'SUSPICIOUS'"
|
||||||
|
>
|
||||||
|
@if (!allAnswered) {
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<span>
|
||||||
|
Answer every verification point to determine the outcome —
|
||||||
|
{{ strChecklist.length - answeredCount }} still pending.
|
||||||
|
</span>
|
||||||
|
} @else if (derivedConclusion === 'NOT_SUSPICIOUS') {
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<span>
|
||||||
|
<strong>Not suspicious.</strong> All {{ strChecklist.length }} points found genuine — the case will be
|
||||||
|
closed and nothing filed with FIU-IND.
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<span>
|
||||||
|
<strong>Suspicious — STR must be filed.</strong>
|
||||||
|
{{ failedFindings.length }} of {{ strChecklist.length }} points not satisfied:
|
||||||
|
{{ failedFindings[0].label }}
|
||||||
|
@if (failedFindings.length > 1) {
|
||||||
|
<span> and {{ failedFindings.length - 1 }} more</span>
|
||||||
|
}
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="fiu-btn fiu-btn-xml" [disabled]="!allAnswered" (click)="concludeStrCase()">
|
||||||
|
Conclude as
|
||||||
|
{{ allAnswered ? (derivedConclusion === 'SUSPICIOUS' ? 'Suspicious' : 'Not Suspicious') : '…' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (selectedStrCase.status === 'CONCLUDED_SUSPICIOUS') {
|
||||||
|
<div class="file-str-row">
|
||||||
|
<label for="fiuFileReferenceNo">FIU-IND acknowledgement / reference number</label>
|
||||||
|
<input id="fiuFileReferenceNo" class="fiu-inp" type="text" [(ngModel)]="fileReferenceNo" />
|
||||||
|
<button class="fiu-btn fiu-btn-xml" (click)="fileStrCase()">Mark STR Filed</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
}
|
||||||
|
|
||||||
<!-- FINnet export template (separate concern — STR XML file-format generation) -->
|
<!-- FINnet export template (separate concern — STR XML file-format generation) -->
|
||||||
<div class="fiu-form-meta">
|
<div class="fiu-form-meta">
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:host,
|
:host {
|
||||||
.fiu-dialog {
|
|
||||||
--f-navy: #0b1e3d;
|
--f-navy: #0b1e3d;
|
||||||
--f-blue: #1d4ed8;
|
--f-blue: #1d4ed8;
|
||||||
--f-blue-l: #eff6ff;
|
--f-blue-l: #eff6ff;
|
||||||
@@ -1859,8 +1858,3 @@
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The STR case popup renders in the overlay, outside :host, so it carries the page's colour variables itself. */
|
|
||||||
.fiu-dialog {
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,12 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, OnInit, inject, ViewChild, TemplateRef } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
|
||||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
|
||||||
import { forkJoin, of } from 'rxjs';
|
|
||||||
import { exportWorkbook } from 'app/cashier/cashier-export';
|
|
||||||
import { catchError } from 'rxjs/operators';
|
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
@@ -136,134 +131,6 @@ export class FiuComponent implements OnInit {
|
|||||||
private sanctionsLogsService = inject(SanctionsLogsService);
|
private sanctionsLogsService = inject(SanctionsLogsService);
|
||||||
private authenticationService = inject(AuthenticationService);
|
private authenticationService = inject(AuthenticationService);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private dialog = inject(MatDialog);
|
|
||||||
private snackBar = inject(MatSnackBar);
|
|
||||||
|
|
||||||
/** The STR case investigation, shown in a popup rather than a panel pushed in under the case list. */
|
|
||||||
@ViewChild('strCaseDialog') strCaseDialogTpl!: TemplateRef<unknown>;
|
|
||||||
private strCaseDialogRef: MatDialogRef<unknown> | null = null;
|
|
||||||
|
|
||||||
/** The CTR report whose customers are open in the popup, and the lines behind it. */
|
|
||||||
@ViewChild('ctrReportDialog') ctrReportDialogTpl!: TemplateRef<unknown>;
|
|
||||||
private ctrReportDialogRef: MatDialogRef<unknown> | null = null;
|
|
||||||
ctrReportView: any = null;
|
|
||||||
ctrReportLines: any[] = [];
|
|
||||||
ctrReportLinesLoading = false;
|
|
||||||
|
|
||||||
/** Opens a generated CTR report with every customer and transaction it names. */
|
|
||||||
openCtrReport(report: any): void {
|
|
||||||
this.ctrReportView = report;
|
|
||||||
this.ctrReportLines = [];
|
|
||||||
this.ctrReportLinesLoading = true;
|
|
||||||
this.amlService.getCtrReportLines(report.id).subscribe({
|
|
||||||
next: (lines) => {
|
|
||||||
this.ctrReportLines = lines || [];
|
|
||||||
this.ctrReportLinesLoading = false;
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.ctrReportLinesLoading = false;
|
|
||||||
this.toast(`Could not load the customers in the ${report.reportMonth} report`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.ctrReportDialogRef?.close();
|
|
||||||
this.ctrReportDialogRef = this.dialog.open(this.ctrReportDialogTpl, {
|
|
||||||
width: '1280px',
|
|
||||||
maxWidth: '96vw',
|
|
||||||
maxHeight: '92vh',
|
|
||||||
autoFocus: false
|
|
||||||
});
|
|
||||||
this.ctrReportDialogRef.afterClosed().subscribe(() => {
|
|
||||||
this.ctrReportView = null;
|
|
||||||
this.ctrReportDialogRef = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
closeCtrReport(): void {
|
|
||||||
this.ctrReportDialogRef?.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Total of the amounts in the open report, so the popup states what the filing adds up to. */
|
|
||||||
get ctrReportTotal(): number {
|
|
||||||
return this.ctrReportLines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Excel workbook of a CTR report with the customer behind every line. Fetches the lines itself unless the popup
|
|
||||||
* already holds them, so it works straight from the reports table as well as from inside the popup.
|
|
||||||
*/
|
|
||||||
exportCtrReport(report: any): void {
|
|
||||||
const build = (lines: any[]) => this.buildCtrReportWorkbook(report, lines);
|
|
||||||
if (this.ctrReportView?.id === report.id && !this.ctrReportLinesLoading) {
|
|
||||||
build(this.ctrReportLines);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.amlService.getCtrReportLines(report.id).subscribe({
|
|
||||||
next: (lines) => build(lines || []),
|
|
||||||
error: () => this.toast(`Could not load the ${report.reportMonth} report to export it`)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async buildCtrReportWorkbook(report: any, lines: any[]): Promise<void> {
|
|
||||||
const total = lines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0);
|
|
||||||
await exportWorkbook(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
sheetName: `CTR ${report.reportMonth}`,
|
|
||||||
title: `Cash Transaction Report — ${report.reportMonth}`,
|
|
||||||
subtitle: `${report.status} · ${lines.length} record${lines.length === 1 ? '' : 's'} · total ₹${total.toLocaleString('en-IN')} · generated ${new Date().toLocaleString('en-IN')}`,
|
|
||||||
headers: [
|
|
||||||
'Report Month',
|
|
||||||
'Customer',
|
|
||||||
'Customer ID / CKYC Ref',
|
|
||||||
'CKYC Number',
|
|
||||||
'Customer Type',
|
|
||||||
'Account No',
|
|
||||||
'Branch',
|
|
||||||
'Account Status',
|
|
||||||
'Account Opened',
|
|
||||||
'Transaction ID',
|
|
||||||
'Transaction Date',
|
|
||||||
'Direction',
|
|
||||||
'Line Type',
|
|
||||||
'Amount (₹)'
|
|
||||||
],
|
|
||||||
rows: lines.map((l) => [
|
|
||||||
l.reportMonth || report.reportMonth,
|
|
||||||
l.clientName || '',
|
|
||||||
l.clientExternalId || '',
|
|
||||||
l.ckycNumber || '',
|
|
||||||
l.individual === false ? 'Entity' : 'Individual',
|
|
||||||
l.accountNo || '',
|
|
||||||
l.branchName || '',
|
|
||||||
l.accountStatus || '',
|
|
||||||
toIsoDate(l.accountOpenedOn) || '',
|
|
||||||
l.transactionId ?? null,
|
|
||||||
toIsoDate(l.transactionDate) || '',
|
|
||||||
l.direction || '',
|
|
||||||
l.connectedGroup ? 'Connected group — month total' : 'Single transaction',
|
|
||||||
Number(l.amount) || 0
|
|
||||||
]),
|
|
||||||
widths: [
|
|
||||||
12,
|
|
||||||
28,
|
|
||||||
22,
|
|
||||||
18,
|
|
||||||
14,
|
|
||||||
20,
|
|
||||||
18,
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
14,
|
|
||||||
16,
|
|
||||||
12,
|
|
||||||
28,
|
|
||||||
18
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
`CTR-${report.reportMonth}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
/** Active tab */
|
/** Active tab */
|
||||||
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
activeTab: 'dashboard' | 'ctr' | 'str' | 'customers' | 'files' = 'dashboard';
|
||||||
|
|
||||||
@@ -278,8 +145,6 @@ export class FiuComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
ctrTxns: CtrTransaction[] = [];
|
ctrTxns: CtrTransaction[] = [];
|
||||||
ctrLinesLoading = false;
|
ctrLinesLoading = false;
|
||||||
/** Reportable cash transactions already posted but not yet in a generated CTR, listed automatically. */
|
|
||||||
ctrPendingCount = 0;
|
|
||||||
ctrLinesError = '';
|
ctrLinesError = '';
|
||||||
/**
|
/**
|
||||||
* ATM / card-rail cash, which feeds the same CTR as the counter transactions in TC2.
|
* ATM / card-rail cash, which feeds the same CTR as the counter transactions in TC2.
|
||||||
@@ -817,23 +682,10 @@ export class FiuComponent implements OnInit {
|
|||||||
loadCtrLines(): void {
|
loadCtrLines(): void {
|
||||||
this.ctrLinesLoading = true;
|
this.ctrLinesLoading = true;
|
||||||
this.ctrLinesError = '';
|
this.ctrLinesError = '';
|
||||||
// The pending list is an extra: if it cannot be read, the generated lines still show.
|
this.amlService.getCtrReportLines().subscribe({
|
||||||
forkJoin({
|
next: (lines) => {
|
||||||
generated: this.amlService.getCtrReportLines(),
|
|
||||||
pending: this.amlService.getCtrPendingLines().pipe(catchError(() => of([] as any[])))
|
|
||||||
}).subscribe({
|
|
||||||
next: ({ generated, pending }) => {
|
|
||||||
this.ctrLinesLoading = false;
|
this.ctrLinesLoading = false;
|
||||||
const generatedRows = (generated || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1));
|
this.ctrTxns = (lines || []).map((l: any, i: number) => this.toCtrTransaction(l, i + 1));
|
||||||
const pendingRows = (pending || []).map((l: any, i: number) => ({
|
|
||||||
...this.toCtrTransaction(l, generatedRows.length + i + 1),
|
|
||||||
pendingFiling: true
|
|
||||||
}));
|
|
||||||
this.ctrPendingCount = pendingRows.length;
|
|
||||||
this.ctrTxns = [
|
|
||||||
...generatedRows,
|
|
||||||
...pendingRows
|
|
||||||
];
|
|
||||||
// Cached so the compliance flags an officer sets below survive a reload, keyed on the rows
|
// Cached so the compliance flags an officer sets below survive a reload, keyed on the rows
|
||||||
// that actually came back rather than on a seeded set.
|
// that actually came back rather than on a seeded set.
|
||||||
this.applyStoredCtrCompliance();
|
this.applyStoredCtrCompliance();
|
||||||
@@ -841,7 +693,6 @@ export class FiuComponent implements OnInit {
|
|||||||
error: (err: any) => {
|
error: (err: any) => {
|
||||||
this.ctrLinesLoading = false;
|
this.ctrLinesLoading = false;
|
||||||
this.ctrTxns = [];
|
this.ctrTxns = [];
|
||||||
this.ctrPendingCount = 0;
|
|
||||||
this.ctrLinesError =
|
this.ctrLinesError =
|
||||||
err?.error?.defaultUserMessage ||
|
err?.error?.defaultUserMessage ||
|
||||||
'Could not load the generated CTR lines. Generate a report for the month first.';
|
'Could not load the generated CTR lines. Generate a report for the month first.';
|
||||||
@@ -969,17 +820,6 @@ export class FiuComponent implements OnInit {
|
|||||||
this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || '';
|
this.concludeSourceOfFundsNotes = strCase.sourceOfFundsNotes || '';
|
||||||
this.fileReferenceNo = strCase.strReferenceNo || '';
|
this.fileReferenceNo = strCase.strReferenceNo || '';
|
||||||
this.resetChecklist();
|
this.resetChecklist();
|
||||||
this.strCaseDialogRef?.close();
|
|
||||||
this.strCaseDialogRef = this.dialog.open(this.strCaseDialogTpl, {
|
|
||||||
width: '1100px',
|
|
||||||
maxWidth: '96vw',
|
|
||||||
maxHeight: '92vh',
|
|
||||||
autoFocus: false
|
|
||||||
});
|
|
||||||
this.strCaseDialogRef.afterClosed().subscribe(() => {
|
|
||||||
this.selectedStrCase = null;
|
|
||||||
this.strCaseDialogRef = null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Plain-language outcome for a live STR case, shown in the investigation panel. */
|
/** Plain-language outcome for a live STR case, shown in the investigation panel. */
|
||||||
@@ -997,7 +837,6 @@ export class FiuComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeStrCaseDetail(): void {
|
closeStrCaseDetail(): void {
|
||||||
this.strCaseDialogRef?.close();
|
|
||||||
this.selectedStrCase = null;
|
this.selectedStrCase = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1032,9 +871,8 @@ export class FiuComponent implements OnInit {
|
|||||||
? `STR case #${updated.id} concluded suspicious — file with FIU-IND by ${updated.fiuDueDate}`
|
? `STR case #${updated.id} concluded suspicious — file with FIU-IND by ${updated.fiuDueDate}`
|
||||||
: `STR case #${updated.id} concluded not suspicious — no filing required`
|
: `STR case #${updated.id} concluded not suspicious — no filing required`
|
||||||
);
|
);
|
||||||
|
this.selectedStrCase = updated;
|
||||||
this.loadAmlStrCases();
|
this.loadAmlStrCases();
|
||||||
// Concluded: close the popup so the case's new status is what the officer sees in the list.
|
|
||||||
this.closeStrCaseDetail();
|
|
||||||
},
|
},
|
||||||
error: this.apiError(`Concluding STR case #${this.selectedStrCase.id}`)
|
error: this.apiError(`Concluding STR case #${this.selectedStrCase.id}`)
|
||||||
});
|
});
|
||||||
@@ -1051,8 +889,8 @@ export class FiuComponent implements OnInit {
|
|||||||
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe({
|
this.amlService.fileStr(this.selectedStrCase.id, this.fileReferenceNo).subscribe({
|
||||||
next: (updated) => {
|
next: (updated) => {
|
||||||
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`);
|
this.toast(`STR case #${updated.id} recorded as filed (ref ${updated.strReferenceNo})`);
|
||||||
|
this.selectedStrCase = updated;
|
||||||
this.loadAmlStrCases();
|
this.loadAmlStrCases();
|
||||||
this.closeStrCaseDetail();
|
|
||||||
},
|
},
|
||||||
error: this.apiError(`Filing STR case #${this.selectedStrCase.id}`)
|
error: this.apiError(`Filing STR case #${this.selectedStrCase.id}`)
|
||||||
});
|
});
|
||||||
@@ -1946,12 +1784,6 @@ export class FiuComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast(msg: string): void {
|
toast(msg: string): void {
|
||||||
// The page's own toast sits behind the popup's backdrop, so while the STR case popup is open the message would be
|
|
||||||
// there but unseen - which read as the action having done nothing. A snackbar is drawn above the popup.
|
|
||||||
if (this.strCaseDialogRef || this.ctrReportDialogRef) {
|
|
||||||
this.snackBar.open(msg, 'OK', { duration: 5000 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.toastMsg = msg;
|
this.toastMsg = msg;
|
||||||
this.toastVisible = true;
|
this.toastVisible = true;
|
||||||
if (this.toastTimer) clearTimeout(this.toastTimer);
|
if (this.toastTimer) clearTimeout(this.toastTimer);
|
||||||
|
|||||||
-852
@@ -1,852 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="bkd">
|
|
||||||
<div class="bkd-head">
|
|
||||||
<div class="bkd-head-left">
|
|
||||||
<div class="bkd-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
|
|
||||||
<div>
|
|
||||||
<h2 class="bkd-title">ALM Time-Bucket Breakdown</h2>
|
|
||||||
<p class="bkd-sub">Day-wise breakdown of FD / RD / Loan from the as-on date</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bkd-head-actions">
|
|
||||||
@if (view === 'config') {
|
|
||||||
<button type="button" class="bkd-btn" [class.bkd-btn-on]="editMode" (click)="toggleEdit()">
|
|
||||||
{{ editMode ? 'Done editing lines' : 'Edit lines' }}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="bkd-btn" (click)="reset()">Reset to RBI defaults</button>
|
|
||||||
}
|
|
||||||
<button class="bkd-close" (click)="close()" matTooltip="Close" aria-label="Close">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bkd-tabs" role="tablist">
|
|
||||||
<button type="button" role="tab" class="bkd-tab" [class.on]="view === 'days'" (click)="view = 'days'">
|
|
||||||
Day-wise breakdown
|
|
||||||
</button>
|
|
||||||
<button type="button" role="tab" class="bkd-tab" [class.on]="view === 'config'" (click)="view = 'config'">
|
|
||||||
Configuration table
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bkd-body">
|
|
||||||
@if (view === 'config') {
|
|
||||||
<p class="bkd-note">
|
|
||||||
<fa-icon icon="info-circle"></fa-icon> Each line shows which bucket its money goes to. You can change the
|
|
||||||
<strong>%</strong> and the rule. Changes are saved in this browser straight away. Hover over a line name to see
|
|
||||||
why it is slotted that way.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if (problemCount > 0) {
|
|
||||||
<p class="bkd-warn bkd-banner">
|
|
||||||
{{ problemCount }} line(s) have a % total that is not 100 or is not filled in yet. See the red or yellow
|
|
||||||
totals below.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (view === 'config') {
|
|
||||||
<!-- The date the figures are as on, and how they are read -->
|
|
||||||
<div class="bkd-toolbar">
|
|
||||||
<label>
|
|
||||||
<span>As-on date</span>
|
|
||||||
<input type="date" [(ngModel)]="asOnIso" (ngModelChange)="loadBreakdown()" aria-label="As-on date" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Amount unit</span>
|
|
||||||
<select [(ngModel)]="unit" aria-label="Unit">
|
|
||||||
@for (u of units; track u.value) {
|
|
||||||
<option [value]="u.value">{{ u.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div class="bkd-toolbar-state">
|
|
||||||
@if (loading) {
|
|
||||||
<span>Loading data from accounts…</span>
|
|
||||||
} @else if (loadError) {
|
|
||||||
<span class="bkd-warn">{{ loadError }}</span>
|
|
||||||
} @else if (breakdown) {
|
|
||||||
<span class="bkd-ok"> FD, RD and Loan data as on {{ breakdown.asOnDate }}. </span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (view === 'days') {
|
|
||||||
<section class="bkd-days-view">
|
|
||||||
<h4 class="bkd-h4 bkd-h4-first">1. Select</h4>
|
|
||||||
<div class="bkd-modes" role="tablist">
|
|
||||||
<button type="button" class="bkd-mode" [class.on]="selectBy === 'types'" (click)="setSelectBy('types')">
|
|
||||||
By account type
|
|
||||||
</button>
|
|
||||||
<button type="button" class="bkd-mode" [class.on]="selectBy === 'accounts'" (click)="setSelectBy('accounts')">
|
|
||||||
Individual accounts
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (selectBy === 'types') {
|
|
||||||
<p class="bkd-days-intro">Tick one or more. You can pick from both sides together.</p>
|
|
||||||
@for (grp of pickGroups; track grp.side) {
|
|
||||||
<div class="bkd-pick-group">
|
|
||||||
<div
|
|
||||||
class="bkd-pick-title"
|
|
||||||
[class.bkd-pick-liab]="grp.side === 'liability'"
|
|
||||||
[class.bkd-pick-asset]="grp.side === 'asset'"
|
|
||||||
>
|
|
||||||
{{ grp.title }}
|
|
||||||
</div>
|
|
||||||
<div class="bkd-pick-list">
|
|
||||||
@for (t of grp.items; track t.value) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="bkd-pick-opt"
|
|
||||||
[class.on]="isTypePicked(t.value)"
|
|
||||||
role="checkbox"
|
|
||||||
[attr.aria-checked]="isTypePicked(t.value)"
|
|
||||||
(click)="toggleType(t.value)"
|
|
||||||
>
|
|
||||||
<span class="bkd-pick-box">{{ isTypePicked(t.value) ? '✓' : '' }}</span>
|
|
||||||
{{ t.label }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="bkd-pick-actions">
|
|
||||||
<button type="button" class="bkd-link" (click)="selectAllTypes()">Select all</button>
|
|
||||||
<button type="button" class="bkd-link" (click)="clearTypes()">Clear</button>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="bkd-search">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="searchText"
|
|
||||||
(ngModelChange)="onSearchInput()"
|
|
||||||
placeholder="Search by account number or customer name"
|
|
||||||
aria-label="Search accounts"
|
|
||||||
/>
|
|
||||||
<select [(ngModel)]="searchKind" (ngModelChange)="onSearchInput()" aria-label="Kind of account">
|
|
||||||
<option value="">All accounts</option>
|
|
||||||
<option value="FD">FD</option>
|
|
||||||
<option value="RD">RD</option>
|
|
||||||
<option value="LOAN">Loan</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (searching) {
|
|
||||||
<p class="bkd-days-note">Searching…</p>
|
|
||||||
} @else if (searchError) {
|
|
||||||
<p class="bkd-warn">{{ searchError }}</p>
|
|
||||||
} @else if (searchText.trim().length < 2) {
|
|
||||||
<p class="bkd-days-note">Type at least 2 characters of an account number or a customer name.</p>
|
|
||||||
} @else if (!searchResults.length) {
|
|
||||||
<p class="bkd-days-note">No accounts found.</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (searchResults.length) {
|
|
||||||
<div class="bkd-hits">
|
|
||||||
@for (hit of searchResults; track hit.kind + '-' + hit.id) {
|
|
||||||
<button type="button" class="bkd-hit" [class.picked]="isPicked(hit)" (click)="pick(hit)">
|
|
||||||
<span class="bkd-hit-kind">{{ hit.kind }}</span>
|
|
||||||
<span class="bkd-hit-main">
|
|
||||||
<strong>{{ hit.accountNo }}</strong> · {{ hit.customer }}
|
|
||||||
<small
|
|
||||||
>{{ hit.product }}
|
|
||||||
@if (hit.date) {
|
|
||||||
· matures {{ hit.date | date: 'dd-MMM-yyyy' }}
|
|
||||||
}
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
<span class="bkd-hit-amt">{{ amountText(hit.amount) }}</span>
|
|
||||||
<span class="bkd-hit-state">{{ isPicked(hit) ? 'Selected' : 'Select' }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (picked.length) {
|
|
||||||
<div class="bkd-picked">
|
|
||||||
<strong>Selected ({{ picked.length }})</strong>
|
|
||||||
@for (p of picked; track p.kind + '-' + p.id) {
|
|
||||||
<span class="bkd-pill-sel">
|
|
||||||
{{ p.kind }} {{ p.accountNo }}
|
|
||||||
<button type="button" (click)="unpick(p)" aria-label="Remove">✕</button>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
<button type="button" class="bkd-link" (click)="clearPicked()">Clear all</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<h4 class="bkd-h4">2. Date and unit</h4>
|
|
||||||
<div class="bkd-submit-row">
|
|
||||||
<label>
|
|
||||||
<span>As-on date</span>
|
|
||||||
<input type="date" [(ngModel)]="asOnIso" (ngModelChange)="onAsOnChange()" aria-label="As-on date" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Amount unit</span>
|
|
||||||
<select [(ngModel)]="unit" aria-label="Amount unit">
|
|
||||||
@for (u of units; track u.value) {
|
|
||||||
<option [value]="u.value">{{ u.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button type="button" class="bkd-btn bkd-btn-on bkd-submit" [disabled]="!canSubmit" (click)="submitReport()">
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (reportShown && selectBy === 'types') {
|
|
||||||
<section class="bkd-report-out">
|
|
||||||
<h4 class="bkd-h4 bkd-report-title">
|
|
||||||
{{ typeColumns.length }} account {{ typeColumns.length === 1 ? 'type' : 'types' }}
|
|
||||||
<small>as on {{ asOnIso }} · Amounts in {{ unitWord }}</small>
|
|
||||||
</h4>
|
|
||||||
@if (loading) {
|
|
||||||
<p class="bkd-days-note">Loading data from accounts…</p>
|
|
||||||
}
|
|
||||||
@if (loadError) {
|
|
||||||
<p class="bkd-warn">{{ loadError }}</p>
|
|
||||||
}
|
|
||||||
@if (breakdown) {
|
|
||||||
@for (c of typeColumns; track c.value) {
|
|
||||||
@if (c.notice) {
|
|
||||||
<p class="bkd-warn bkd-banner">{{ c.label }}: {{ c.notice }}</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
<div class="bkd-acc-wrap bkd-matrix-wrap">
|
|
||||||
<table class="bkd-tbl bkd-matrix">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Bucket</th>
|
|
||||||
<th>Days</th>
|
|
||||||
<th>Dates</th>
|
|
||||||
@for (c of typeColumns; track c.value) {
|
|
||||||
<th
|
|
||||||
class="bkd-num"
|
|
||||||
[class.bkd-pick-liab]="c.side === 'liability'"
|
|
||||||
[class.bkd-pick-asset]="c.side === 'asset'"
|
|
||||||
>
|
|
||||||
{{ c.label }}
|
|
||||||
</th>
|
|
||||||
}
|
|
||||||
@if (typeHasLiability && typeHasAsset) {
|
|
||||||
<th class="bkd-num bkd-pick-liab">Liabilities total</th>
|
|
||||||
<th class="bkd-num bkd-pick-asset">Assets total</th>
|
|
||||||
} @else if (typeColumns.length > 1) {
|
|
||||||
<th class="bkd-num">Total</th>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (r of typeMatrix; track r.label) {
|
|
||||||
<tr [class.bkd-dim]="!r.liab && !r.asset">
|
|
||||||
<td class="bkd-b-name">{{ r.label }}</td>
|
|
||||||
<td>{{ r.days }}</td>
|
|
||||||
<td>{{ r.dates }}</td>
|
|
||||||
@for (v of r.cells; track $index) {
|
|
||||||
<td class="bkd-num">{{ amountText(v) }}</td>
|
|
||||||
}
|
|
||||||
@if (typeHasLiability && typeHasAsset) {
|
|
||||||
<td class="bkd-num">{{ amountText(r.liab) }}</td>
|
|
||||||
<td class="bkd-num">{{ amountText(r.asset) }}</td>
|
|
||||||
} @else if (typeColumns.length > 1) {
|
|
||||||
<td class="bkd-num">{{ amountText(r.liab + r.asset) }}</td>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="3">Total</td>
|
|
||||||
@for (c of typeColumns; track c.value) {
|
|
||||||
<td class="bkd-num">{{ amountText(c.total) }}</td>
|
|
||||||
}
|
|
||||||
@if (typeHasLiability && typeHasAsset) {
|
|
||||||
<td class="bkd-num">{{ amountText(liabilityGrandTotal) }}</td>
|
|
||||||
<td class="bkd-num">{{ amountText(assetGrandTotal) }}</td>
|
|
||||||
} @else if (typeColumns.length > 1) {
|
|
||||||
<td class="bkd-num">{{ amountText(liabilityGrandTotal + assetGrandTotal) }}</td>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p class="bkd-days-note">
|
|
||||||
Deposits and term loans are slotted by their own dates: a deposit once on its maturity date, a loan once
|
|
||||||
for each unpaid instalment on its due date. Anything already due goes in the first bucket (a loan
|
|
||||||
instalment follows the Overdue rule). CC / OD and NPA loans are divided by the bucket-wise % set for
|
|
||||||
them in the Configuration table. Deposit amounts are balances, loan amounts are principal; interest is a
|
|
||||||
separate line of the statement.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (reportShown && selectBy === 'accounts') {
|
|
||||||
<section class="bkd-report-out">
|
|
||||||
<h4 class="bkd-h4 bkd-report-title">
|
|
||||||
{{ picked.length }} {{ picked.length === 1 ? 'account' : 'accounts' }}
|
|
||||||
<small>as on {{ asOnIso }} · Amounts in {{ unitWord }}</small>
|
|
||||||
</h4>
|
|
||||||
@if (reportLoading) {
|
|
||||||
<p class="bkd-days-note">Loading…</p>
|
|
||||||
}
|
|
||||||
@if (reportError) {
|
|
||||||
<p class="bkd-warn">{{ reportError }}</p>
|
|
||||||
}
|
|
||||||
@if (report; as rep) {
|
|
||||||
<table class="bkd-tbl bkd-focus-tbl">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Bucket</th>
|
|
||||||
<th>Days</th>
|
|
||||||
<th>Dates</th>
|
|
||||||
<th class="bkd-num">Amount</th>
|
|
||||||
<th class="bkd-num">%</th>
|
|
||||||
<th class="bkd-num">Entries</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (r of reportBuckets; track r.label) {
|
|
||||||
<tr [class.bkd-dim]="!r.amount">
|
|
||||||
<td class="bkd-b-name">{{ r.label }}</td>
|
|
||||||
<td>{{ r.days }}</td>
|
|
||||||
<td>{{ r.dates }}</td>
|
|
||||||
<td class="bkd-num">{{ amountText(r.amount) }}</td>
|
|
||||||
<td class="bkd-num">{{ r.amount ? (r.pct | number: '1.0-1') + '%' : '' }}</td>
|
|
||||||
<td class="bkd-num">{{ r.count || '' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colspan="3">Total</td>
|
|
||||||
<td class="bkd-num">{{ amountText(rep.total) }}</td>
|
|
||||||
<td class="bkd-num">{{ rep.total ? '100%' : '' }}</td>
|
|
||||||
<td class="bkd-num">{{ rep.count }}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
<p class="bkd-days-note">
|
|
||||||
A deposit counts once, on its maturity date; a loan counts once for each unpaid instalment, on its due
|
|
||||||
date. Anything already due goes in the first bucket (a loan instalment follows the Overdue rule).
|
|
||||||
Deposit amounts are balances, loan amounts are the principal of each instalment; interest is a separate
|
|
||||||
line of the statement.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h4 class="bkd-h4">Each entry</h4>
|
|
||||||
<div class="bkd-acc-wrap">
|
|
||||||
<table class="bkd-tbl bkd-acc">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Account</th>
|
|
||||||
<th>Customer</th>
|
|
||||||
<th>Product</th>
|
|
||||||
<th>Due / maturity date</th>
|
|
||||||
<th class="bkd-num">Days</th>
|
|
||||||
<th>Bucket</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th class="bkd-num">Amount</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (r of rep.rows; track $index) {
|
|
||||||
<tr>
|
|
||||||
<td class="bkd-b-name">
|
|
||||||
{{ r.kind }} {{ r.accountNo }}
|
|
||||||
@if (r.installment) {
|
|
||||||
<span class="bkd-b-hint">instalment {{ r.installment }}</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>{{ r.customer }}</td>
|
|
||||||
<td>
|
|
||||||
{{ r.product }}
|
|
||||||
@if (r.note) {
|
|
||||||
<div class="bkd-warn">{{ r.note }}</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>{{ r.date ? (r.date | date: 'dd-MMM-yyyy') : '—' }}</td>
|
|
||||||
<td class="bkd-num">{{ r.days === null ? '' : r.days }}</td>
|
|
||||||
<td>{{ bucketName(r.bucket) }}</td>
|
|
||||||
<td>{{ r.status }}</td>
|
|
||||||
<td class="bkd-num">{{ amountText(r.amount) }}</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="8" class="bkd-empty">Nothing to show for these accounts on this date.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
} @else {
|
|
||||||
<section class="bkd-setup">
|
|
||||||
<div class="bkd-range-head">
|
|
||||||
<h4 class="bkd-h4 bkd-h4-first">Bucket ranges</h4>
|
|
||||||
<button type="button" class="bkd-btn bkd-btn-sm" [class.bkd-btn-on]="rangeEdit" (click)="toggleRangeEdit()">
|
|
||||||
{{ rangeEdit ? 'Done' : 'Edit' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="bkd-days-intro">
|
|
||||||
@if (rangeEdit) {
|
|
||||||
Change the Start / End of a bucket, or add and remove buckets. When you change the End of a bucket, the
|
|
||||||
Start of the next one and its heading change with it.
|
|
||||||
} @else {
|
|
||||||
Start and End of each bucket. Click Edit to change, add or remove buckets.
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
<table class="bkd-tbl bkd-range">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>Start</th>
|
|
||||||
<th>End</th>
|
|
||||||
<th>Heading</th>
|
|
||||||
<th>Days / Dates</th>
|
|
||||||
@if (rangeEdit) {
|
|
||||||
<th></th>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (b of cfg.buckets; track $index) {
|
|
||||||
<tr>
|
|
||||||
<td class="bkd-range-n">{{ $index + 1 }}</td>
|
|
||||||
<td>
|
|
||||||
@if (fromOf($index); as f) {
|
|
||||||
@if (rangeEdit) {
|
|
||||||
<span class="bkd-span-edit">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
[ngModel]="f.n"
|
|
||||||
(ngModelChange)="setFrom($index, { n: $event })"
|
|
||||||
aria-label="Start"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
[ngModel]="f.unit"
|
|
||||||
(ngModelChange)="setFrom($index, { unit: $event })"
|
|
||||||
aria-label="Start unit"
|
|
||||||
>
|
|
||||||
<option value="day">d</option>
|
|
||||||
<option value="month">m</option>
|
|
||||||
<option value="year">y</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
<span class="bkd-range-val">{{ f.n }} {{ unitLetter(f.unit) }}</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
@if (b.upTo; as t) {
|
|
||||||
@if (rangeEdit) {
|
|
||||||
<span class="bkd-span-edit">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
[ngModel]="t.n"
|
|
||||||
(ngModelChange)="setTo($index, { n: $event })"
|
|
||||||
aria-label="End"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
[ngModel]="t.unit"
|
|
||||||
(ngModelChange)="setTo($index, { unit: $event })"
|
|
||||||
aria-label="End unit"
|
|
||||||
>
|
|
||||||
<option value="day">d</option>
|
|
||||||
<option value="month">m</option>
|
|
||||||
<option value="year">y</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
<span class="bkd-range-val">{{ t.n }} {{ unitLetter(t.unit) }}</span>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<span class="bkd-none">end</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="bkd-b-name">
|
|
||||||
{{ b.label }}
|
|
||||||
@if (rangeProblem($index); as problem) {
|
|
||||||
<div class="bkd-warn">{{ problem }}</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="bkd-b-hint">
|
|
||||||
@if (windows[$index]; as w) {
|
|
||||||
{{ dayRange(w) }} · {{ dateRange(w) }}
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
@if (rangeEdit) {
|
|
||||||
<td>
|
|
||||||
@if (cfg.buckets.length > 2) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="bkd-x"
|
|
||||||
(click)="removeBucket($index)"
|
|
||||||
matTooltip="Remove this bucket"
|
|
||||||
aria-label="Remove bucket"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
@if (rangeEdit) {
|
|
||||||
<!-- A bucket the rules did not have yesterday: add it here, it lands in date order -->
|
|
||||||
<div class="bkd-addbucket">
|
|
||||||
<span class="bkd-addbucket-title">+ Add a bucket ending at:</span>
|
|
||||||
<span class="bkd-span-edit">
|
|
||||||
<input type="number" min="1" [(ngModel)]="newEnd.n" aria-label="End of the new bucket" />
|
|
||||||
<select [(ngModel)]="newEnd.unit" aria-label="Unit of the new bucket">
|
|
||||||
<option value="day">d</option>
|
|
||||||
<option value="month">m</option>
|
|
||||||
<option value="year">y</option>
|
|
||||||
</select>
|
|
||||||
</span>
|
|
||||||
<button type="button" class="bkd-btn bkd-btn-sm" (click)="addBucket()">Add</button>
|
|
||||||
<span class="bkd-b-hint">
|
|
||||||
It is placed in the right position automatically and splits the bucket it falls inside into two.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="bkd-calc">
|
|
||||||
<label>
|
|
||||||
<span>Check a maturity date</span>
|
|
||||||
<input type="date" [(ngModel)]="maturityIso" aria-label="Maturity date" />
|
|
||||||
</label>
|
|
||||||
@if (dayCheck; as c) {
|
|
||||||
<div class="bkd-calc-out">
|
|
||||||
@if (c.due) {
|
|
||||||
<strong>Already due</strong> ({{ -c.days }} days ago) → <strong>{{ c.bucket }}</strong>
|
|
||||||
} @else {
|
|
||||||
<strong>{{ c.days }} days left</strong> → <strong>{{ c.bucket }}</strong>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="bkd-calc-out bkd-none">Pick a maturity date and its bucket will show here.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ═══ The configuration table, laid out like the filed statement ═══ -->
|
|
||||||
@for (sec of sections; track sec.side) {
|
|
||||||
<div class="bkd-sheet-wrap">
|
|
||||||
<table class="bkd-sheet">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th colspan="3" rowspan="2" class="s-title">{{ sec.title }}</th>
|
|
||||||
<th [attr.colspan]="cfg.buckets.length" class="s-span">RESIDUAL MATURITY</th>
|
|
||||||
<th rowspan="2" class="s-total-h">Total</th>
|
|
||||||
@if (editMode) {
|
|
||||||
<th rowspan="2" class="s-tools-h">Line</th>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
@for (b of cfg.buckets; track $index) {
|
|
||||||
<th class="s-bucket">{{ b.label }}</th>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (item of itemsOf(sec.side); track item.id) {
|
|
||||||
@if (item.header) {
|
|
||||||
<tr class="s-head">
|
|
||||||
<td class="s-ref">
|
|
||||||
@if (editMode) {
|
|
||||||
<input type="text" [(ngModel)]="item.ref" (ngModelChange)="changed()" aria-label="Number" />
|
|
||||||
} @else {
|
|
||||||
{{ item.ref }}
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td colspan="2" class="s-name">
|
|
||||||
@if (editMode) {
|
|
||||||
<input type="text" [(ngModel)]="item.name" (ngModelChange)="changed()" aria-label="Naam" />
|
|
||||||
} @else {
|
|
||||||
{{ item.name }}
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td [attr.colspan]="cfg.buckets.length + 1" class="s-head-fill"></td>
|
|
||||||
@if (editMode) {
|
|
||||||
<td class="s-tools">
|
|
||||||
<button type="button" class="bkd-x" (click)="removeLine(item)" aria-label="Hatayein">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
} @else {
|
|
||||||
<tr [class.s-off]="item.included === false">
|
|
||||||
<td class="s-ref">
|
|
||||||
@if (editMode) {
|
|
||||||
<input type="text" [(ngModel)]="item.ref" (ngModelChange)="changed()" aria-label="Number" />
|
|
||||||
} @else {
|
|
||||||
{{ item.ref }}
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="s-name"
|
|
||||||
[class.s-sub]="item.level === 1"
|
|
||||||
[matTooltip]="item.reason"
|
|
||||||
matTooltipPosition="above"
|
|
||||||
>
|
|
||||||
@if (editMode) {
|
|
||||||
<input type="text" [(ngModel)]="item.name" (ngModelChange)="changed()" aria-label="Naam" />
|
|
||||||
} @else {
|
|
||||||
{{ item.name }}
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="s-rule">
|
|
||||||
<select
|
|
||||||
[ngModel]="item.rule"
|
|
||||||
(ngModelChange)="ruleChanged(item, $event)"
|
|
||||||
aria-label="Rule"
|
|
||||||
[matTooltip]="item.reason"
|
|
||||||
>
|
|
||||||
@for (r of ruleOptions; track r.value) {
|
|
||||||
<option [value]="r.value">{{ r.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
@if (item.rule === 'maturity' || item.rule === 'behavioural') {
|
|
||||||
<!-- Which accounts fill a due-date line, or give a percentage-split line its total -->
|
|
||||||
<select
|
|
||||||
class="s-src"
|
|
||||||
[(ngModel)]="item.source"
|
|
||||||
(ngModelChange)="changed()"
|
|
||||||
aria-label="Which accounts the amount comes from"
|
|
||||||
matTooltip="Which accounts the amount of this line comes from"
|
|
||||||
>
|
|
||||||
@for (o of sourceOptions; track o.value) {
|
|
||||||
<option [value]="o.value">{{ o.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
@if (item.rule === 'maturity') {
|
|
||||||
@if (autoAmounts(item); as amounts) {
|
|
||||||
<!-- Filled by the accounts themselves, each in the bucket of its own date -->
|
|
||||||
@for (v of amounts; track $index) {
|
|
||||||
<td class="s-auto" [class.s-auto-zero]="!v">{{ amountText(v) }}</td>
|
|
||||||
}
|
|
||||||
<td class="s-tot s-auto-total" [matTooltip]="autoAccounts(item) + ' accounts'">
|
|
||||||
{{ amountText(autoTotal(amounts)) }}
|
|
||||||
</td>
|
|
||||||
} @else {
|
|
||||||
<td [attr.colspan]="cfg.buckets.length" class="s-due">
|
|
||||||
@if (!item.source) {
|
|
||||||
This line has to be filled by hand: it has no account source yet
|
|
||||||
} @else if (loading) {
|
|
||||||
Data aa raha hai…
|
|
||||||
} @else {
|
|
||||||
No data came from the accounts
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="s-tot">—</td>
|
|
||||||
}
|
|
||||||
} @else if (item.rule === 'fixed') {
|
|
||||||
<!-- A fixed line sits in one bucket: click the bucket it should move to -->
|
|
||||||
@for (b of cfg.buckets; track $index) {
|
|
||||||
<td
|
|
||||||
class="s-cell s-pick"
|
|
||||||
[class.s-cell-on]="share(item, $index) !== null"
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
[attr.aria-label]="'Put it in ' + b.label"
|
|
||||||
[matTooltip]="share(item, $index) !== null ? b.label : 'Yahan le jaayein: ' + b.label"
|
|
||||||
(click)="setFixed(item, $index)"
|
|
||||||
(keydown.enter)="setFixed(item, $index)"
|
|
||||||
(keydown.space)="setFixed(item, $index); $event.preventDefault()"
|
|
||||||
>
|
|
||||||
{{ share(item, $index) !== null ? '100%' : '' }}
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
@for (b of cfg.buckets; track $index) {
|
|
||||||
<td class="s-cell s-cell-input" [class.s-cell-on]="share(item, $index) !== null">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="any"
|
|
||||||
placeholder="%"
|
|
||||||
[ngModel]="share(item, $index)"
|
|
||||||
(ngModelChange)="setShare(item, $index, $event)"
|
|
||||||
[attr.aria-label]="b.label"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
<td
|
|
||||||
class="s-tot"
|
|
||||||
[class.s-tot-ok]="rowState(item) === 'ok'"
|
|
||||||
[class.s-tot-bad]="rowState(item) === 'bad'"
|
|
||||||
[class.s-tot-todo]="rowState(item) === 'todo'"
|
|
||||||
>
|
|
||||||
{{ rowState(item) === 'todo' ? 'fill in' : rowTotal(item) + '%' }}
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (editMode) {
|
|
||||||
<td class="s-tools">
|
|
||||||
<label class="s-incl" matTooltip="Show in report">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[ngModel]="item.included !== false"
|
|
||||||
(ngModelChange)="item.included = $event; changed()"
|
|
||||||
aria-label="In report"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button type="button" class="bkd-x" (click)="removeLine(item)" aria-label="Hatayein">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
@if (editMode) {
|
|
||||||
<button type="button" class="bkd-btn bkd-btn-sm s-add" (click)="addLine(sec.side)">
|
|
||||||
+ Add {{ sec.title === 'OUTFLOWS' ? 'an Outflow' : 'an Inflow' }} line
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (editMode) {
|
|
||||||
<div class="bkd-bucket-edit">
|
|
||||||
<div class="bkd-editor-sub">Bucket names and day span</div>
|
|
||||||
@for (b of cfg.buckets; track $index) {
|
|
||||||
<div class="bkd-bucket-edit-row">
|
|
||||||
<span class="bkd-step-n">{{ $index + 1 }}</span>
|
|
||||||
<input type="text" [(ngModel)]="b.label" (ngModelChange)="changed()" aria-label="Bucket name" />
|
|
||||||
<input type="text" [(ngModel)]="b.hint" (ngModelChange)="changed()" aria-label="Bucket description" />
|
|
||||||
<div class="bkd-span">
|
|
||||||
@if (b.upTo) {
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
[(ngModel)]="b.upTo.n"
|
|
||||||
(ngModelChange)="bucketsChanged()"
|
|
||||||
aria-label="How far it reaches"
|
|
||||||
/>
|
|
||||||
<select [(ngModel)]="b.upTo.unit" (ngModelChange)="bucketsChanged()" aria-label="Unit">
|
|
||||||
<option value="day">d</option>
|
|
||||||
<option value="month">m</option>
|
|
||||||
<option value="year">y</option>
|
|
||||||
</select>
|
|
||||||
} @else {
|
|
||||||
<span class="bkd-none">No end</span>
|
|
||||||
}
|
|
||||||
<label class="bkd-check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
[ngModel]="!b.upTo"
|
|
||||||
(ngModelChange)="setOpenEnded(b, $event)"
|
|
||||||
aria-label="No end"
|
|
||||||
/>
|
|
||||||
No end
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (cfg.overdue; as o) {
|
|
||||||
<div class="bkd-editor-sub bkd-overdue-title">Overdue (due but not received): which bucket</div>
|
|
||||||
<div class="bkd-overdue">
|
|
||||||
<label>
|
|
||||||
<span>Overdue less than a month</span>
|
|
||||||
<select [(ngModel)]="o.recent" (ngModelChange)="bucketsChanged()" aria-label="Recent overdue bucket">
|
|
||||||
@for (b of cfg.buckets; track $index) {
|
|
||||||
<option [ngValue]="$index">{{ b.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Overdue more than a month</span>
|
|
||||||
<select [(ngModel)]="o.old" (ngModelChange)="bucketsChanged()" aria-label="Old overdue bucket">
|
|
||||||
@for (b of cfg.buckets; track $index) {
|
|
||||||
<option [ngValue]="$index">{{ b.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- What the accounts could not put in a date bucket, so nothing is silently left out -->
|
|
||||||
@if (breakdown; as b) {
|
|
||||||
<section class="bkd-apart-box">
|
|
||||||
<h4 class="bkd-h4">Shown separately (not slotted by date)</h4>
|
|
||||||
<ul class="bkd-apart">
|
|
||||||
@for (n of b.notSlotted; track n.key) {
|
|
||||||
<li>
|
|
||||||
<strong>{{ n.label }}</strong> — {{ n.accounts }} loan, outstanding
|
|
||||||
{{ amountText(n.outstanding) }}
|
|
||||||
<div class="bkd-b-hint">{{ n.why }}</div>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
<ul class="bkd-notes">
|
|
||||||
@for (note of b.notes; track note) {
|
|
||||||
<li>{{ note }}</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- ═══ What the report will show ═══ -->
|
|
||||||
<section class="bkd-report">
|
|
||||||
<h3 class="bkd-h3">What the report will show</h3>
|
|
||||||
<div class="bkd-report-card">
|
|
||||||
<div class="r-title">Statement of Structural Liquidity as on {{ asOnIso }}</div>
|
|
||||||
<div class="r-sub">( Amounts in {{ unitWord }} )</div>
|
|
||||||
</div>
|
|
||||||
<ul class="bkd-report-list">
|
|
||||||
<li>
|
|
||||||
Two tables, <strong>OUTFLOWS</strong> and <strong>INFLOWS</strong>, with the lines above. Each line’s amount
|
|
||||||
sits in its bucket, with a <strong>Total</strong> in the last column.
|
|
||||||
</li>
|
|
||||||
<li><strong>A. TOTAL OUTFLOWS</strong> — the sum of all outflow lines, bucket by bucket.</li>
|
|
||||||
<li><strong>B. TOTAL INFLOWS</strong> — the sum of all inflow lines, bucket by bucket.</li>
|
|
||||||
<li><strong>C. MISMATCH (B − A)</strong> — inflow minus outflow, in each bucket.</li>
|
|
||||||
<li>
|
|
||||||
<strong>D. CUMULATIVE MISMATCH</strong> — running total of C, from the first bucket up to that bucket.
|
|
||||||
</li>
|
|
||||||
<li><strong>E. C as % to A</strong> — mismatch divided by outflow, times 100.</li>
|
|
||||||
@for (l of cfg.limits; track $index) {
|
|
||||||
<li class="bkd-limit">
|
|
||||||
<strong>{{ cfg.buckets[l.bucket]?.label }}</strong
|
|
||||||
>: negative mismatch must not exceed {{ l.pct }}%. The report shows it in red when it does.
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
<p class="bkd-days-note">
|
|
||||||
Where the amount comes from: <strong>By due date</strong> lines take their data from FD, RD and Loan accounts.
|
|
||||||
<strong>Fixed</strong>, <strong>Split</strong> and <strong>By study</strong> lines take the ledger (GL)
|
|
||||||
balance and apply the % above. Lines with no register have to be filled in by hand.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
-1341
File diff suppressed because it is too large
Load Diff
-962
@@ -1,962 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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, DestroyRef, inject } from '@angular/core';
|
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
||||||
import { Subject, catchError, debounceTime, of, switchMap } from 'rxjs';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
||||||
import { businessTodayIso } from 'app/core/utils/business-today';
|
|
||||||
import {
|
|
||||||
AlmBucket,
|
|
||||||
AlmConfig,
|
|
||||||
AlmItem,
|
|
||||||
AlmRule,
|
|
||||||
AlmSide,
|
|
||||||
AlmPlacement,
|
|
||||||
AlmSource,
|
|
||||||
AlmSpan,
|
|
||||||
AlmUnit,
|
|
||||||
RULE_OPTIONS,
|
|
||||||
SHARE_SOURCES,
|
|
||||||
SOURCE_OPTIONS,
|
|
||||||
defaultConfig
|
|
||||||
} from './alm-bucket-breakdown.config';
|
|
||||||
import {
|
|
||||||
BucketWindow,
|
|
||||||
addSpan,
|
|
||||||
bucketIndexFor,
|
|
||||||
bucketLabel,
|
|
||||||
bucketWindows,
|
|
||||||
dayRangeText,
|
|
||||||
daysBetween,
|
|
||||||
formatDate,
|
|
||||||
fromSpanOf,
|
|
||||||
parseIsoDate,
|
|
||||||
setFromSpan
|
|
||||||
} from './alm-bucket.util';
|
|
||||||
import { AccountHit, AccountRow, AlmMaturityService, MaturityBreakdown, SelectedReport } from './alm-maturity.service';
|
|
||||||
|
|
||||||
type AmountUnit = 'rupee' | 'lakh' | 'crore';
|
|
||||||
|
|
||||||
/** How a line's shares stand: whether the bank still has something to fill in. */
|
|
||||||
type RowState = 'spread' | 'ok' | 'todo' | 'bad';
|
|
||||||
|
|
||||||
/** Which of the server's account groups each source adds together. */
|
|
||||||
const SOURCE_GROUPS: Record<'FD_RD' | 'FD' | 'RD' | 'LOAN', string[]> = {
|
|
||||||
FD_RD: [
|
|
||||||
'FD',
|
|
||||||
'RD'
|
|
||||||
],
|
|
||||||
FD: ['FD'],
|
|
||||||
RD: ['RD'],
|
|
||||||
LOAN: ['LOAN']
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The two tables of the statement, in the order it prints them. */
|
|
||||||
const SECTIONS: { side: AlmSide; title: string }[] = [
|
|
||||||
{ side: 'liability', title: 'OUTFLOWS' },
|
|
||||||
{ side: 'asset', title: 'INFLOWS' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where the browser keeps the bank's edits. Same arrangement as the OSS GL mapping: there is no
|
|
||||||
* backend table for this yet, so the setting belongs to the browser it was made in. The suffix is
|
|
||||||
* the shape of what is stored; a different shape starts again from RBI's defaults.
|
|
||||||
*/
|
|
||||||
const STORAGE_KEY = 'alm_bucket_breakdown_config_v2';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ALM Time-Bucket Breakdown — the configuration of the Structural Liquidity Statement.
|
|
||||||
*
|
|
||||||
* One table laid out like the statement the bank files: the same numbered Outflow and Inflow lines
|
|
||||||
* down the side, the same residual-maturity buckets across the top. Each line says how its balance
|
|
||||||
* is slotted — wholly in one bucket, by the due date of each account, or by percentages the bank
|
|
||||||
* sets. The statement itself is computed from this configuration.
|
|
||||||
*
|
|
||||||
* Rules start from RBI's Urban Co-operative Bank defaults but none is fixed: every line, bucket and
|
|
||||||
* percentage can be edited, and "Reset" returns to RBI's.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-alm-bucket-breakdown-dialog',
|
|
||||||
standalone: true,
|
|
||||||
templateUrl: './alm-bucket-breakdown-dialog.component.html',
|
|
||||||
styleUrls: ['./alm-bucket-breakdown-dialog.component.scss'],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
MatDialogModule,
|
|
||||||
MatTooltipModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class AlmBucketBreakdownDialogComponent {
|
|
||||||
private dialogRef = inject(MatDialogRef<AlmBucketBreakdownDialogComponent>);
|
|
||||||
private service = inject(AlmMaturityService);
|
|
||||||
private destroyRef = inject(DestroyRef);
|
|
||||||
|
|
||||||
readonly ruleOptions = RULE_OPTIONS;
|
|
||||||
readonly sourceOptions = SOURCE_OPTIONS;
|
|
||||||
readonly sections = SECTIONS;
|
|
||||||
|
|
||||||
cfg: AlmConfig = this.load();
|
|
||||||
/** Which tab is open: the day breakdown of one product, or the statement's configuration table. */
|
|
||||||
view: 'days' | 'config' = 'days';
|
|
||||||
/** Lines can be renamed, added, removed and left out of the report only while this is on. */
|
|
||||||
editMode = false;
|
|
||||||
|
|
||||||
/** The date the buckets are counted from — the business date, not the browser's clock. */
|
|
||||||
asOnIso = businessTodayIso();
|
|
||||||
/** Maturity typed into the day calculator. */
|
|
||||||
maturityIso = '';
|
|
||||||
|
|
||||||
/** Amounts come back in rupees; the bank reads them in lakhs unless told otherwise. */
|
|
||||||
readonly units: { value: AmountUnit; label: string; divisor: number }[] = [
|
|
||||||
{ value: 'rupee', label: '₹ (rupaye)', divisor: 1 },
|
|
||||||
{ value: 'lakh', label: 'Lakh', divisor: 100_000 },
|
|
||||||
{ value: 'crore', label: 'Crore', divisor: 10_000_000 }
|
|
||||||
];
|
|
||||||
unit: AmountUnit = 'lakh';
|
|
||||||
|
|
||||||
/** FD / RD / loan totals per bucket for the as-on date, from the server. */
|
|
||||||
breakdown: MaturityBreakdown | null = null;
|
|
||||||
loading = false;
|
|
||||||
loadError = '';
|
|
||||||
/** Only the newest request's answer may land; a slow earlier one must not overwrite it. */
|
|
||||||
private loadSeq = 0;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.watchSearch();
|
|
||||||
this.loadBreakdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── The configuration table ──
|
|
||||||
|
|
||||||
/** The lines of one table, headings included, in statement order. */
|
|
||||||
itemsOf(side: AlmSide): AlmItem[] {
|
|
||||||
return this.cfg.items.filter((i) => i.side === side);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Columns across a table: reference, line, rule, every bucket, total, and the row tools while editing. */
|
|
||||||
get columnCount(): number {
|
|
||||||
return 3 + this.cfg.buckets.length + 1 + (this.editMode ? 1 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Share of a line's balance that goes to one bucket, or null when none does. */
|
|
||||||
share(item: AlmItem, bucket: number): number | null {
|
|
||||||
const placed = item.placements.filter((p) => p.bucket === bucket);
|
|
||||||
if (!placed.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const explicit = placed.filter((p) => p.pct !== null && p.pct !== undefined && `${p.pct}` !== '');
|
|
||||||
if (explicit.length) {
|
|
||||||
return explicit.reduce((sum, p) => sum + Number(p.pct), 0);
|
|
||||||
}
|
|
||||||
// No share written down: a line with one bucket puts the whole balance there.
|
|
||||||
return item.placements.length === 1 ? 100 : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set the share going to one bucket; empty or zero takes the bucket out of the line. */
|
|
||||||
setShare(item: AlmItem, bucket: number, raw: number | string | null): void {
|
|
||||||
const value = raw === null || raw === '' ? 0 : Math.max(0, Math.min(100, Number(raw)));
|
|
||||||
const rest = item.placements.filter((p) => p.bucket !== bucket);
|
|
||||||
if (value > 0 && !Number.isNaN(value)) {
|
|
||||||
const kept = item.placements.find((p) => p.bucket === bucket);
|
|
||||||
rest.push({ bucket, pct: value, label: kept?.label });
|
|
||||||
}
|
|
||||||
item.placements = rest.sort((a, b) => a.bucket - b.bucket);
|
|
||||||
this.changed();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The amounts a due-date line gets from its accounts, in rupees; null while there is nothing to
|
|
||||||
* show (no source chosen, not loaded yet, or the server could not answer).
|
|
||||||
*/
|
|
||||||
autoAmounts(item: AlmItem): number[] | null {
|
|
||||||
return item.rule === 'maturity' ? this.itemAmounts(item) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a line has in each bucket, in rupees. Deposits and term loans come bucket by bucket from
|
|
||||||
* their own dates; cash-credit and NPA loans come as one outstanding figure that the line's own
|
|
||||||
* percentages divide. Null when the line has no accounts behind it, or nothing has loaded yet.
|
|
||||||
*/
|
|
||||||
itemAmounts(item: AlmItem | null): number[] | null {
|
|
||||||
return item?.source ? this.amountsOf(item.source, item) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What one kind of account has in each bucket. For cash-credit and NPA loans the percentages come
|
|
||||||
* from the statement line that names that kind, and are zero until that line has them.
|
|
||||||
*/
|
|
||||||
private amountsOf(source: AlmSource, line: AlmItem | null): number[] | null {
|
|
||||||
if (!this.breakdown) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (SHARE_SOURCES.includes(source)) {
|
|
||||||
const outstanding = this.apartLine(source)?.outstanding ?? 0;
|
|
||||||
return this.cfg.buckets.map((_b, i) => (outstanding * (line ? (this.share(line, i) ?? 0) : 0)) / 100);
|
|
||||||
}
|
|
||||||
const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS];
|
|
||||||
const groups = this.breakdown.groups.filter((g) => keys?.includes(g.key));
|
|
||||||
if (!groups.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return this.cfg.buckets.map((_b, i) => groups.reduce((sum, g) => sum + (g.buckets[i] ?? 0), 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The loans the server reports apart from the date buckets: CC/OD, or NPA. */
|
|
||||||
private apartLine(source: AlmSource): { outstanding: number; accounts: number } | undefined {
|
|
||||||
const key = source === 'CCOD' ? 'REVOLVING' : 'NPA';
|
|
||||||
return this.breakdown?.notSlotted.find((n) => n.key === key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Accounts behind a line, for the tooltip and the day view's total. */
|
|
||||||
autoAccounts(item: AlmItem | null): number {
|
|
||||||
const source = item?.source;
|
|
||||||
if (!source || !this.breakdown) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (SHARE_SOURCES.includes(source)) {
|
|
||||||
return this.apartLine(source)?.accounts ?? 0;
|
|
||||||
}
|
|
||||||
const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS] ?? [];
|
|
||||||
return this.breakdown.groups.filter((g) => keys.includes(g.key)).reduce((sum, g) => sum + g.accounts, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
autoTotal(amounts: number[]): number {
|
|
||||||
return amounts.reduce((sum, v) => sum + v, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Picking kinds of account ──
|
|
||||||
|
|
||||||
/** Whether the day-wise breakdown is by kind of account, or for individual accounts found by search. */
|
|
||||||
selectBy: 'types' | 'accounts' = 'types';
|
|
||||||
|
|
||||||
/** The kinds of account that can be picked, on the liability and the asset side. */
|
|
||||||
readonly pickTypes: { value: AlmSource; label: string; side: AlmSide }[] = [
|
|
||||||
{ value: 'FD', label: 'Fixed Deposits (FD)', side: 'liability' },
|
|
||||||
{ value: 'RD', label: 'Recurring Deposits (RD)', side: 'liability' },
|
|
||||||
{ value: 'LOAN', label: 'Term Loans', side: 'asset' },
|
|
||||||
{ value: 'CCOD', label: 'CC / OD Loans', side: 'asset' },
|
|
||||||
{ value: 'NPA', label: 'NPA Loans', side: 'asset' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** The kinds picked; any number of them, from either side. */
|
|
||||||
pickedTypes: AlmSource[] = [
|
|
||||||
'FD',
|
|
||||||
'RD'
|
|
||||||
];
|
|
||||||
|
|
||||||
get pickGroups(): { side: AlmSide; title: string; items: { value: AlmSource; label: string; side: AlmSide }[] }[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
side: 'liability',
|
|
||||||
title: 'Liabilities: deposits (customers)',
|
|
||||||
items: this.pickTypes.filter((t) => t.side === 'liability')
|
|
||||||
},
|
|
||||||
{ side: 'asset', title: 'Assets: loans (customers)', items: this.pickTypes.filter((t) => t.side === 'asset') }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectBy(mode: 'types' | 'accounts'): void {
|
|
||||||
this.selectBy = mode;
|
|
||||||
this.reportShown = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
isTypePicked(value: AlmSource): boolean {
|
|
||||||
return this.pickedTypes.includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Tick or untick a kind. Any number can be ticked together; a report already open is put away. */
|
|
||||||
toggleType(value: AlmSource): void {
|
|
||||||
this.pickedTypes = this.isTypePicked(value) ? this.pickedTypes.filter((v) => v !== value) : [
|
|
||||||
...this.pickedTypes,
|
|
||||||
value
|
|
||||||
];
|
|
||||||
this.reportShown = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectAllTypes(): void {
|
|
||||||
this.pickedTypes = this.pickTypes.map((t) => t.value);
|
|
||||||
this.reportShown = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTypes(): void {
|
|
||||||
this.pickedTypes = [];
|
|
||||||
this.reportShown = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One column per kind picked: what it has in every bucket, and a note where something is still to be filled in. */
|
|
||||||
get typeColumns(): {
|
|
||||||
value: AlmSource;
|
|
||||||
label: string;
|
|
||||||
side: AlmSide;
|
|
||||||
amounts: number[];
|
|
||||||
total: number;
|
|
||||||
notice: string;
|
|
||||||
}[] {
|
|
||||||
return this.pickTypes
|
|
||||||
.filter((t) => this.isTypePicked(t.value))
|
|
||||||
.map((t) => {
|
|
||||||
const amounts =
|
|
||||||
this.amountsOf(t.value, this.cfg.items.find((i) => !i.header && i.source === t.value) ?? null) ??
|
|
||||||
this.cfg.buckets.map(() => 0);
|
|
||||||
const total = this.autoTotal(amounts);
|
|
||||||
const outstanding = SHARE_SOURCES.includes(t.value) ? (this.apartLine(t.value)?.outstanding ?? 0) : 0;
|
|
||||||
const notice =
|
|
||||||
outstanding > 0 && total === 0
|
|
||||||
? `The bucket-wise % for this kind are not filled in yet, so nothing is divided. Fill them in the Configuration table (outstanding is ${this.amountText(outstanding)}).`
|
|
||||||
: '';
|
|
||||||
return { value: t.value, label: t.label, side: t.side, amounts, total, notice };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One row per bucket across the picked kinds, with the liability and asset sides added up separately. */
|
|
||||||
get typeMatrix(): { label: string; days: string; dates: string; cells: number[]; liab: number; asset: number }[] {
|
|
||||||
const cols = this.typeColumns;
|
|
||||||
return this.windows.map((w, i) => {
|
|
||||||
const cells = cols.map((c) => c.amounts[i] ?? 0);
|
|
||||||
return {
|
|
||||||
label: this.cfg.buckets[i]?.label ?? w.label,
|
|
||||||
days: dayRangeText(w),
|
|
||||||
dates: this.dateRange(w),
|
|
||||||
cells,
|
|
||||||
liab: cols.reduce((sum, c, k) => sum + (c.side === 'liability' ? cells[k] : 0), 0),
|
|
||||||
asset: cols.reduce((sum, c, k) => sum + (c.side === 'asset' ? cells[k] : 0), 0)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
get liabilityGrandTotal(): number {
|
|
||||||
return this.typeColumns.reduce((sum, c) => sum + (c.side === 'liability' ? c.total : 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get assetGrandTotal(): number {
|
|
||||||
return this.typeColumns.reduce((sum, c) => sum + (c.side === 'asset' ? c.total : 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get typeHasLiability(): boolean {
|
|
||||||
return this.typeColumns.some((c) => c.side === 'liability');
|
|
||||||
}
|
|
||||||
|
|
||||||
get typeHasAsset(): boolean {
|
|
||||||
return this.typeColumns.some((c) => c.side === 'asset');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Picking accounts one by one ──
|
|
||||||
|
|
||||||
/** What is typed in the search box. */
|
|
||||||
searchText = '';
|
|
||||||
/** Which kind of account the search looks in: all, or one kind. */
|
|
||||||
searchKind: '' | 'FD' | 'RD' | 'LOAN' = '';
|
|
||||||
searchResults: AccountHit[] = [];
|
|
||||||
searching = false;
|
|
||||||
searchError = '';
|
|
||||||
/** The accounts picked so far. */
|
|
||||||
picked: AccountHit[] = [];
|
|
||||||
private searchTyped = new Subject<void>();
|
|
||||||
|
|
||||||
/** Whether the report of the picked accounts is on screen: only after Submit, and until the picks or the date change. */
|
|
||||||
reportShown = false;
|
|
||||||
report: SelectedReport | null = null;
|
|
||||||
reportLoading = false;
|
|
||||||
reportError = '';
|
|
||||||
/** Only the newest request's answer may land; a slow earlier one must not overwrite it. */
|
|
||||||
private reportSeq = 0;
|
|
||||||
|
|
||||||
/** Set up the search so it waits for typing to pause, and a slow answer to an old search never replaces a newer one. */
|
|
||||||
private watchSearch(): void {
|
|
||||||
this.searchTyped
|
|
||||||
.pipe(
|
|
||||||
debounceTime(300),
|
|
||||||
switchMap(() =>
|
|
||||||
this.service.searchAccounts(this.searchText.trim(), this.searchKind).pipe(
|
|
||||||
catchError((err) => {
|
|
||||||
this.searchError =
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
'Search failed. The backend needs a restart with the new endpoint.';
|
|
||||||
return of({ results: [] as AccountHit[] });
|
|
||||||
})
|
|
||||||
)
|
|
||||||
),
|
|
||||||
takeUntilDestroyed(this.destroyRef)
|
|
||||||
)
|
|
||||||
.subscribe((found) => {
|
|
||||||
this.searching = false;
|
|
||||||
this.searchResults = found.results;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Typing or changing the kind: search again once typing pauses. */
|
|
||||||
onSearchInput(): void {
|
|
||||||
this.searchError = '';
|
|
||||||
if (this.searchText.trim().length < 2) {
|
|
||||||
this.searchResults = [];
|
|
||||||
this.searching = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.searching = true;
|
|
||||||
this.searchTyped.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether an account is already picked. */
|
|
||||||
isPicked(hit: AccountHit): boolean {
|
|
||||||
return this.picked.some((p) => p.kind === hit.kind && p.id === hit.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
pick(hit: AccountHit): void {
|
|
||||||
if (!this.isPicked(hit)) {
|
|
||||||
this.picked = [
|
|
||||||
...this.picked,
|
|
||||||
hit
|
|
||||||
];
|
|
||||||
this.reportShown = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unpick(hit: AccountHit): void {
|
|
||||||
this.picked = this.picked.filter((p) => !(p.kind === hit.kind && p.id === hit.id));
|
|
||||||
this.reportShown = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearPicked(): void {
|
|
||||||
this.picked = [];
|
|
||||||
this.reportShown = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Show the day-wise breakdown of the picked accounts for the chosen date. */
|
|
||||||
submitReport(): void {
|
|
||||||
if (this.selectBy === 'types') {
|
|
||||||
if (this.pickedTypes.length) {
|
|
||||||
this.reportShown = true;
|
|
||||||
this.loadBreakdown();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.picked.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.reportShown = true;
|
|
||||||
this.loadReport();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether Submit has anything to submit. */
|
|
||||||
get canSubmit(): boolean {
|
|
||||||
return this.selectBy === 'types' ? this.pickedTypes.length > 0 : this.picked.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch the report of the picked accounts; also called again when the bucket setting changes under an open report. */
|
|
||||||
private loadReport(): void {
|
|
||||||
const seq = ++this.reportSeq;
|
|
||||||
this.reportError = '';
|
|
||||||
if (!parseIsoDate(this.asOnIso)) {
|
|
||||||
this.report = null;
|
|
||||||
this.reportError = 'The as-on date is not valid.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.bucketSpecInvalid) {
|
|
||||||
this.report = null;
|
|
||||||
this.reportError = 'Only the last bucket may have no end; every other bucket needs an end.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.reportLoading = true;
|
|
||||||
this.service
|
|
||||||
.getSelected(this.picked.map((p) => `${p.kind}:${p.id}`).join(','), {
|
|
||||||
asOnDate: this.asOnIso,
|
|
||||||
buckets: this.edgeSpec,
|
|
||||||
overdueRecentBucket: this.cfg.overdue?.recent ?? 0,
|
|
||||||
overdueOldBucket: this.cfg.overdue?.old ?? 2
|
|
||||||
})
|
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe({
|
|
||||||
next: (result) => {
|
|
||||||
if (seq === this.reportSeq) {
|
|
||||||
this.reportLoading = false;
|
|
||||||
this.report = result;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
if (seq === this.reportSeq) {
|
|
||||||
this.reportLoading = false;
|
|
||||||
this.report = null;
|
|
||||||
this.reportError =
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
'No data came back. The backend needs a restart with the new endpoint.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A new as-on date means a new report: put the old one away until Submit is pressed again. */
|
|
||||||
onAsOnChange(): void {
|
|
||||||
this.reportShown = false;
|
|
||||||
this.loadBreakdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The report by bucket: what the picked accounts put in each one, with the days and dates of the bucket. */
|
|
||||||
get reportBuckets(): { label: string; days: string; dates: string; amount: number; count: number; pct: number }[] {
|
|
||||||
const rows = this.report?.rows ?? [];
|
|
||||||
const total = rows.reduce((sum, r) => sum + r.amount, 0);
|
|
||||||
return this.windows.map((w, i) => {
|
|
||||||
const inBucket = rows.filter((r) => r.bucket === i);
|
|
||||||
const amount = inBucket.reduce((sum, r) => sum + r.amount, 0);
|
|
||||||
return {
|
|
||||||
label: this.cfg.buckets[i]?.label ?? w.label,
|
|
||||||
days: dayRangeText(w),
|
|
||||||
dates: this.dateRange(w),
|
|
||||||
amount,
|
|
||||||
count: inBucket.length,
|
|
||||||
pct: total ? (amount / total) * 100 : 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
bucketName(index: number | null): string {
|
|
||||||
return index === null ? '' : (this.cfg.buckets[index]?.label ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── The bucket ranges, as a From / To table ──
|
|
||||||
|
|
||||||
/** Where a bucket starts, as the table writes it; null for the first, which always starts on day 1. */
|
|
||||||
fromOf(index: number): AlmSpan | null {
|
|
||||||
return fromSpanOf(this.cfg.buckets, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Change where a bucket starts. That is where the one before it ends, so that one moves too. */
|
|
||||||
setFrom(index: number, change: { n?: number | string; unit?: AlmUnit }): void {
|
|
||||||
const current = this.fromOf(index);
|
|
||||||
if (!current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const n = change.n !== undefined ? Number(change.n) : current.n;
|
|
||||||
this.applyRange(index - 1, () =>
|
|
||||||
setFromSpan(this.cfg.buckets, index, { n: n > 0 ? n : 1, unit: change.unit ?? current.unit })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Change where a bucket ends. */
|
|
||||||
setTo(index: number, change: { n?: number | string; unit?: AlmUnit }): void {
|
|
||||||
const current = this.cfg.buckets[index]?.upTo;
|
|
||||||
if (!current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const n = change.n !== undefined ? Number(change.n) : current.n;
|
|
||||||
this.applyRange(index, () => {
|
|
||||||
this.cfg.buckets[index].upTo = { n: n > 0 ? n : 1, unit: change.unit ?? current.unit };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a change to the end of one bucket, and keep the headings of the two buckets it touches in
|
|
||||||
* step — but only a heading still worded the way the table would word it; one the bank has typed
|
|
||||||
* over is left alone.
|
|
||||||
*/
|
|
||||||
private applyRange(endingIndex: number, apply: () => void): void {
|
|
||||||
const touched = [
|
|
||||||
endingIndex,
|
|
||||||
endingIndex + 1
|
|
||||||
].filter((i) => i >= 0 && i < this.cfg.buckets.length);
|
|
||||||
const wasGenerated = touched.map((i) => this.cfg.buckets[i].label === bucketLabel(this.cfg.buckets, i));
|
|
||||||
apply();
|
|
||||||
touched.forEach((i, k) => {
|
|
||||||
if (wasGenerated[k]) {
|
|
||||||
this.cfg.buckets[i].label = bucketLabel(this.cfg.buckets, i);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.bucketsChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The end typed into the "add a bucket" form. */
|
|
||||||
newEnd: { n: number; unit: AlmUnit } = { n: 7, unit: 'day' };
|
|
||||||
|
|
||||||
/** Start, End, adding and removing buckets work only while this is on, so a stray click cannot change them. */
|
|
||||||
rangeEdit = false;
|
|
||||||
|
|
||||||
toggleRangeEdit(): void {
|
|
||||||
this.rangeEdit = !this.rangeEdit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 'd', 'm' or 'y' — a unit the way the range table writes it. */
|
|
||||||
unitLetter(unit: AlmUnit): string {
|
|
||||||
return unit === 'day' ? 'd' : unit === 'month' ? 'm' : 'y';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a bucket ending where the form says. It lands in date order and takes the front part of the
|
|
||||||
* bucket it falls inside, so what was placed there stays with the later part; lines, the overdue
|
|
||||||
* rule and limits keep pointing at the buckets they pointed at.
|
|
||||||
*/
|
|
||||||
addBucket(): void {
|
|
||||||
const end: AlmSpan = { n: Math.max(1, Number(this.newEnd.n) || 1), unit: this.newEnd.unit };
|
|
||||||
const buckets = this.cfg.buckets;
|
|
||||||
const reference = parseIsoDate(this.asOnIso) ?? new Date();
|
|
||||||
const endTime = addSpan(reference, end).getTime();
|
|
||||||
if (buckets.some((b) => b.upTo && addSpan(reference, b.upTo).getTime() === endTime)) {
|
|
||||||
window.alert('A bucket already ends there.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let position = buckets.findIndex((b) => !b.upTo || addSpan(reference, b.upTo).getTime() > endTime);
|
|
||||||
if (position < 0) {
|
|
||||||
position = buckets.length;
|
|
||||||
}
|
|
||||||
const laterWasGenerated = position < buckets.length && buckets[position].label === bucketLabel(buckets, position);
|
|
||||||
this.shiftBucketRefs((i) => (i >= position ? i + 1 : i));
|
|
||||||
// A bucket put in front becomes the first and starts on day 1; the old first now starts where it ends.
|
|
||||||
if (position === 0 && buckets[0]) {
|
|
||||||
buckets[0].startSpan = undefined;
|
|
||||||
}
|
|
||||||
buckets.splice(position, 0, { label: '', hint: '', upTo: end });
|
|
||||||
buckets[position].label = bucketLabel(buckets, position);
|
|
||||||
if (laterWasGenerated && position + 1 < buckets.length) {
|
|
||||||
buckets[position + 1].label = bucketLabel(buckets, position + 1);
|
|
||||||
}
|
|
||||||
this.bucketsChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a bucket. Whatever was placed in it moves to the next one (to the one before, if it was
|
|
||||||
* the last, which then runs on without end), so no line loses its money.
|
|
||||||
*/
|
|
||||||
removeBucket(index: number): void {
|
|
||||||
const buckets = this.cfg.buckets;
|
|
||||||
if (buckets.length <= 2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!window.confirm(`Remove the "${buckets[index].label}" bucket? Its money will move to the next bucket.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const last = index === buckets.length - 1;
|
|
||||||
const target = last ? index - 1 : index + 1;
|
|
||||||
const targetWasGenerated = buckets[target].label === bucketLabel(buckets, target);
|
|
||||||
this.shiftBucketRefs((i) => {
|
|
||||||
const moved = i === index ? target : i;
|
|
||||||
return moved > index ? moved - 1 : moved;
|
|
||||||
});
|
|
||||||
const removedStart = buckets[index].startSpan;
|
|
||||||
buckets.splice(index, 1);
|
|
||||||
// Removing the first bucket makes the next one the first, which keeps the statement's start.
|
|
||||||
if (index === 0 && removedStart) {
|
|
||||||
buckets[0].startSpan = removedStart;
|
|
||||||
}
|
|
||||||
if (last) {
|
|
||||||
buckets[index - 1].upTo = null;
|
|
||||||
}
|
|
||||||
const now = last ? index - 1 : index;
|
|
||||||
if (targetWasGenerated) {
|
|
||||||
buckets[now].label = bucketLabel(buckets, now);
|
|
||||||
}
|
|
||||||
this.mergeShares();
|
|
||||||
this.bucketsChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-point everything that names a bucket by its position: lines, the overdue rule, the limits. */
|
|
||||||
private shiftBucketRefs(map: (index: number) => number): void {
|
|
||||||
this.cfg.items.forEach((item) => item.placements.forEach((p) => (p.bucket = map(p.bucket))));
|
|
||||||
if (this.cfg.overdue) {
|
|
||||||
this.cfg.overdue.recent = map(this.cfg.overdue.recent);
|
|
||||||
this.cfg.overdue.old = map(this.cfg.overdue.old);
|
|
||||||
}
|
|
||||||
this.cfg.limits.forEach((l) => (l.bucket = map(l.bucket)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** After two buckets became one, a line's two shares there become one share, and a limit is kept once. */
|
|
||||||
private mergeShares(): void {
|
|
||||||
this.cfg.items.forEach((item) => {
|
|
||||||
const merged = new Map<number, AlmPlacement>();
|
|
||||||
for (const p of item.placements) {
|
|
||||||
const have = merged.get(p.bucket);
|
|
||||||
if (!have) {
|
|
||||||
merged.set(p.bucket, { ...p });
|
|
||||||
} else if (have.pct != null || p.pct != null) {
|
|
||||||
have.pct = (Number(have.pct) || 0) + (Number(p.pct) || 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item.placements = [...merged.values()].sort((a, b) => a.bucket - b.bucket);
|
|
||||||
});
|
|
||||||
const seen = new Set<number>();
|
|
||||||
this.cfg.limits = this.cfg.limits.filter((l) => !seen.has(l.bucket) && !!seen.add(l.bucket));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A bucket that ends before it starts, or no later than the one before. Empty when the range is fine. */
|
|
||||||
rangeProblem(index: number): string {
|
|
||||||
const w = this.windows[index];
|
|
||||||
if (!w || !w.to) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return w.to < w.from ? 'End is before Start: keep it later than the end of the previous bucket.' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Move a whole line to one bucket: its balance all goes there and nowhere else. */
|
|
||||||
setFixed(item: AlmItem, bucket: number): void {
|
|
||||||
const label = item.placements.find((p) => p.bucket === bucket)?.label;
|
|
||||||
item.placements = [{ bucket, pct: 100, label }];
|
|
||||||
this.changed();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a line's shares add up to, or null for a line that follows due dates. */
|
|
||||||
rowTotal(item: AlmItem): number | null {
|
|
||||||
if (item.rule === 'maturity') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return this.cfg.buckets.reduce((sum, _b, i) => sum + (this.share(item, i) ?? 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
rowState(item: AlmItem): RowState {
|
|
||||||
const total = this.rowTotal(item);
|
|
||||||
if (total === null) {
|
|
||||||
return 'spread';
|
|
||||||
}
|
|
||||||
if (total === 0) {
|
|
||||||
return 'todo';
|
|
||||||
}
|
|
||||||
return Math.abs(total - 100) < 0.005 ? 'ok' : 'bad';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shares that do not make up the whole balance, over every line that is in the report. */
|
|
||||||
get problemCount(): number {
|
|
||||||
return this.cfg.items.filter(
|
|
||||||
(i) => !i.header && i.included !== false && [
|
|
||||||
'bad',
|
|
||||||
'todo'
|
|
||||||
].includes(this.rowState(i))
|
|
||||||
).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Changing a line's rule: due-date lines follow each account's date, the rest carry shares. */
|
|
||||||
ruleChanged(item: AlmItem, rule: AlmRule): void {
|
|
||||||
item.rule = rule;
|
|
||||||
item.spreads = rule === 'maturity';
|
|
||||||
if (rule === 'fixed') {
|
|
||||||
// A fixed line sits in one bucket: keep the one that held the most, or start in the first.
|
|
||||||
const biggest = [...item.placements].sort((a, b) => (Number(b.pct) || 0) - (Number(a.pct) || 0))[0];
|
|
||||||
item.placements = [{ bucket: biggest?.bucket ?? 0, pct: 100, label: biggest?.label }];
|
|
||||||
}
|
|
||||||
this.changed();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Editing the lines themselves ──
|
|
||||||
|
|
||||||
toggleEdit(): void {
|
|
||||||
this.editMode = !this.editMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
addLine(side: AlmSide): void {
|
|
||||||
const items = this.itemsOf(side);
|
|
||||||
const last = items[items.length - 1];
|
|
||||||
this.cfg.items.splice(this.cfg.items.indexOf(last) + 1, 0, {
|
|
||||||
id: `u${Date.now()}`,
|
|
||||||
side,
|
|
||||||
ref: '',
|
|
||||||
level: 0,
|
|
||||||
name: side === 'liability' ? 'Nayi liability' : 'Nayi asset',
|
|
||||||
rule: 'fixed',
|
|
||||||
reason: '',
|
|
||||||
spreads: false,
|
|
||||||
placements: [{ bucket: 0, pct: 100 }]
|
|
||||||
});
|
|
||||||
this.changed();
|
|
||||||
}
|
|
||||||
|
|
||||||
removeLine(item: AlmItem): void {
|
|
||||||
if (!window.confirm(`"${item.name}" hata dein?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.cfg.items = this.cfg.items.filter((i) => i.id !== item.id);
|
|
||||||
this.changed();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open-ended means "and beyond" — only the last bucket should be. */
|
|
||||||
setOpenEnded(bucket: AlmBucket, open: boolean): void {
|
|
||||||
bucket.upTo = open ? null : { n: 5, unit: 'year' };
|
|
||||||
this.bucketsChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called after any edit — the change is kept right away, there is no separate save step. */
|
|
||||||
changed(): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.cfg));
|
|
||||||
} catch {
|
|
||||||
/* private window or blocked storage — the edit still holds for this session */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
if (!window.confirm('All settings will go back to the RBI defaults and your changes will be lost. Continue?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
/* nothing stored to remove */
|
|
||||||
}
|
|
||||||
this.cfg = defaultConfig();
|
|
||||||
this.loadBreakdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
this.dialogRef.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Days from the as-on date ──
|
|
||||||
|
|
||||||
/** Every bucket as dates, for the chosen as-on date. */
|
|
||||||
get windows(): BucketWindow[] {
|
|
||||||
const asOn = parseIsoDate(this.asOnIso);
|
|
||||||
return asOn ? bucketWindows(asOn, this.cfg.buckets) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Day-by-day text for a window: 'Din 1 se 14'. */
|
|
||||||
dayRange(w: BucketWindow): string {
|
|
||||||
return dayRangeText(w);
|
|
||||||
}
|
|
||||||
|
|
||||||
dateRange(w: BucketWindow): string {
|
|
||||||
return w.to ? `${formatDate(w.from)} to ${formatDate(w.to)}` : `${formatDate(w.from)} onwards`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the day calculator says for the maturity typed in; null until both dates are valid. */
|
|
||||||
get dayCheck(): { days: number; bucket: string; due: boolean } | null {
|
|
||||||
const asOn = parseIsoDate(this.asOnIso);
|
|
||||||
const maturity = parseIsoDate(this.maturityIso);
|
|
||||||
if (!asOn || !maturity) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const days = daysBetween(asOn, maturity);
|
|
||||||
return {
|
|
||||||
days,
|
|
||||||
due: days <= 0,
|
|
||||||
bucket: this.cfg.buckets[bucketIndexFor(asOn, maturity, this.cfg.buckets)]?.label ?? ''
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Live FD / RD / loan totals for the as-on date ──
|
|
||||||
|
|
||||||
/** The bank's bucket edges as the server reads them: '14d,28d,3m,6m,1y,3y,5y'. */
|
|
||||||
private get edgeSpec(): string {
|
|
||||||
const letter = { day: 'd', month: 'm', year: 'y' };
|
|
||||||
return this.cfg.buckets
|
|
||||||
.filter((b) => b.upTo)
|
|
||||||
.map((b) => `${b.upTo?.n}${letter[b.upTo?.unit ?? 'day']}`)
|
|
||||||
.join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Only the last bucket may be open-ended, and every other one needs an end. */
|
|
||||||
private get bucketSpecInvalid(): boolean {
|
|
||||||
const last = this.cfg.buckets.length - 1;
|
|
||||||
return this.cfg.buckets.some((b, i) => !b.upTo && i < last) || !!this.cfg.buckets[last]?.upTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch the fixed-term breakdown for the chosen as-on date and the current bucket setting. */
|
|
||||||
loadBreakdown(): void {
|
|
||||||
const seq = ++this.loadSeq;
|
|
||||||
this.loadError = '';
|
|
||||||
if (!parseIsoDate(this.asOnIso)) {
|
|
||||||
this.breakdown = null;
|
|
||||||
this.loadError = 'The as-on date is not valid.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.bucketSpecInvalid) {
|
|
||||||
this.breakdown = null;
|
|
||||||
this.loadError = 'Only the last bucket may have no end; every other bucket needs an end.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.loading = true;
|
|
||||||
this.service
|
|
||||||
.getBreakdown({
|
|
||||||
asOnDate: this.asOnIso,
|
|
||||||
buckets: this.edgeSpec,
|
|
||||||
overdueRecentBucket: this.cfg.overdue?.recent ?? 0,
|
|
||||||
overdueOldBucket: this.cfg.overdue?.old ?? 2
|
|
||||||
})
|
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe({
|
|
||||||
next: (result) => {
|
|
||||||
if (seq !== this.loadSeq) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.loading = false;
|
|
||||||
this.breakdown = result;
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
if (seq !== this.loadSeq) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.loading = false;
|
|
||||||
this.breakdown = null;
|
|
||||||
this.loadError =
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
'No data came back. The backend needs a restart with the new endpoint.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A bucket edge or the overdue rule changed: the server must slot everything again. */
|
|
||||||
bucketsChanged(): void {
|
|
||||||
this.changed();
|
|
||||||
this.loadBreakdown();
|
|
||||||
if (this.reportShown && this.selectBy === 'accounts') {
|
|
||||||
this.loadReport();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The unit as the statement's heading words it: 'Lakhs of Rupees'. */
|
|
||||||
get unitWord(): string {
|
|
||||||
return { rupee: 'Rupees', lakh: 'Lakhs of Rupees', crore: 'Crores of Rupees' }[this.unit];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An amount in rupees as the chosen unit; a dash when there is nothing in it. */
|
|
||||||
amountText(rupees: number): string {
|
|
||||||
if (!rupees) {
|
|
||||||
return '–';
|
|
||||||
}
|
|
||||||
const divisor = this.units.find((u) => u.value === this.unit)?.divisor ?? 1;
|
|
||||||
return (rupees / divisor).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The saved edits if they are intact, otherwise RBI's defaults. */
|
|
||||||
private load(): AlmConfig {
|
|
||||||
const fallback = defaultConfig();
|
|
||||||
try {
|
|
||||||
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') as AlmConfig | null;
|
|
||||||
if (
|
|
||||||
saved &&
|
|
||||||
Array.isArray(saved.buckets) &&
|
|
||||||
saved.buckets.length >= 2 &&
|
|
||||||
Array.isArray(saved.items) &&
|
|
||||||
Array.isArray(saved.limits)
|
|
||||||
) {
|
|
||||||
const overdue = (saved.overdue ??= fallback.overdue ?? { recent: 0, old: 2 });
|
|
||||||
// Buckets can be added and removed, so anything pointing past the last one is cut back.
|
|
||||||
const last = saved.buckets.length - 1;
|
|
||||||
overdue.recent = Math.min(overdue.recent, last);
|
|
||||||
overdue.old = Math.min(overdue.old, last);
|
|
||||||
saved.limits = saved.limits.filter((l) => l.bucket <= last);
|
|
||||||
saved.items.forEach((item) => {
|
|
||||||
item.placements = (item.placements ?? []).filter((p) => p.bucket <= last);
|
|
||||||
});
|
|
||||||
// Lines saved before due-date lines could name their accounts take the default's, by id.
|
|
||||||
saved.items.forEach((item) => {
|
|
||||||
if (item.source === undefined) {
|
|
||||||
item.source = fallback.items.find((d) => d.id === item.id)?.source ?? '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* unreadable — start from the defaults */
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Structural Liquidity Statement of an Urban Co-operative Bank (non-scheduled — 8 residual-maturity
|
|
||||||
* buckets, 1-14 days not split), laid out row for row like the statement the bank files: the same
|
|
||||||
* numbered Outflow and Inflow lines, the same buckets.
|
|
||||||
*
|
|
||||||
* Everything in this file is only the DEFAULT. It follows RBI's Urban Co-operative Banks (Asset
|
|
||||||
* Liability Management) circular of 17 September 2008 and the 2025 Directions, and where the bank's
|
|
||||||
* own filed statement slots a line differently (Interest Payable, Provisions, Other Liabilities) it
|
|
||||||
* follows the bank's statement. The screen works on a copy the bank can edit and falls back to these
|
|
||||||
* values on "Reset"; percentages in particular are a benchmark that a bank may replace with its own
|
|
||||||
* behavioural study approved by the Board / ALCO.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type AlmSide = 'liability' | 'asset';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How the bucket is decided.
|
|
||||||
* - fixed: always the same bucket(s).
|
|
||||||
* - maturity: follows the due date of each account/instalment, so it spreads over the buckets.
|
|
||||||
* - split: one balance divided between buckets by share.
|
|
||||||
* - behavioural: the bank's own past-data study decides.
|
|
||||||
*/
|
|
||||||
export type AlmRule = 'fixed' | 'maturity' | 'split' | 'behavioural';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which accounts feed a due-date line on their own. Empty means nothing does yet and the line has to
|
|
||||||
* be filled by hand. FD_RD is the two deposit kinds added together, as RBI's "Term Deposits" head is.
|
|
||||||
*/
|
|
||||||
export type AlmSource = 'FD' | 'RD' | 'FD_RD' | 'LOAN' | 'CCOD' | 'NPA' | '';
|
|
||||||
|
|
||||||
export const SOURCE_OPTIONS: { value: AlmSource; label: string }[] = [
|
|
||||||
{ value: '', label: 'None (fill by hand)' },
|
|
||||||
{ value: 'FD_RD', label: 'FD + RD accounts (by maturity)' },
|
|
||||||
{ value: 'FD', label: 'FD accounts only (by maturity)' },
|
|
||||||
{ value: 'RD', label: 'RD accounts only (by maturity)' },
|
|
||||||
{ value: 'LOAN', label: 'Term Loan accounts (by instalment)' },
|
|
||||||
{ value: 'CCOD', label: 'CC / OD loan accounts (outstanding × %)' },
|
|
||||||
{ value: 'NPA', label: 'NPA loan accounts (outstanding × %)' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Sources whose accounts give one outstanding figure that the line's own percentages then divide. */
|
|
||||||
export const SHARE_SOURCES: AlmSource[] = [
|
|
||||||
'CCOD',
|
|
||||||
'NPA'
|
|
||||||
];
|
|
||||||
|
|
||||||
export type AlmUnit = 'day' | 'month' | 'year';
|
|
||||||
|
|
||||||
/** How far out a bucket reaches, counted from the as-on date. */
|
|
||||||
export interface AlmSpan {
|
|
||||||
n: number;
|
|
||||||
unit: AlmUnit;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AlmBucket {
|
|
||||||
/** The column heading, as the statement prints it. */
|
|
||||||
label: string;
|
|
||||||
/** One line a branch user can follow. */
|
|
||||||
hint: string;
|
|
||||||
/**
|
|
||||||
* Last day the bucket takes, counted from the as-on date. Calendar months and years are used
|
|
||||||
* as they are, so '3 months' from 30 June is 30 September. Null means no upper end.
|
|
||||||
*/
|
|
||||||
upTo: AlmSpan | null;
|
|
||||||
/**
|
|
||||||
* Only on the first bucket: where it starts, when that is not day 1. Every later bucket starts where
|
|
||||||
* the one before it ends. Money due before this start still counts in the first bucket, since
|
|
||||||
* nothing may fall outside the statement.
|
|
||||||
*/
|
|
||||||
startSpan?: AlmSpan;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AlmPlacement {
|
|
||||||
/** Index into the bucket list. */
|
|
||||||
bucket: number;
|
|
||||||
/** Share of the balance that goes to this bucket, 0-100; blank when the whole line goes there. */
|
|
||||||
pct?: number | null;
|
|
||||||
/** Short name for the portion, e.g. 'Core'. */
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AlmItem {
|
|
||||||
id: string;
|
|
||||||
side: AlmSide;
|
|
||||||
/** The statement's own numbering: '1.', '3.', 'I)'. */
|
|
||||||
ref: string;
|
|
||||||
/** 0 for a numbered head, 1 for a line under a head. */
|
|
||||||
level: 0 | 1;
|
|
||||||
/** A heading only, such as '3. Deposits': it carries no figure of its own. */
|
|
||||||
header?: boolean;
|
|
||||||
/** Left out of the report when false; shown when absent or true. */
|
|
||||||
included?: boolean;
|
|
||||||
name: string;
|
|
||||||
rule: AlmRule;
|
|
||||||
/** Plain-language reason, in the words a branch manager would use. */
|
|
||||||
reason: string;
|
|
||||||
/** True when each account falls in the bucket of its own due date. */
|
|
||||||
spreads: boolean;
|
|
||||||
/** For a due-date line: the accounts whose maturities fill it. */
|
|
||||||
source?: AlmSource;
|
|
||||||
placements: AlmPlacement[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prudential cap on the net cumulative negative mismatch, as a % of cumulative outflow in that bucket. */
|
|
||||||
export interface AlmLimit {
|
|
||||||
bucket: number;
|
|
||||||
pct: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where an instalment or receivable that is already due and unpaid is slotted — the bank's Overdue
|
|
||||||
* Receivables rule. Both are bucket positions: 'recent' for one that fell due less than a month ago,
|
|
||||||
* 'old' for anything older.
|
|
||||||
*/
|
|
||||||
export interface AlmOverdueRule {
|
|
||||||
recent: number;
|
|
||||||
old: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AlmConfig {
|
|
||||||
buckets: AlmBucket[];
|
|
||||||
items: AlmItem[];
|
|
||||||
limits: AlmLimit[];
|
|
||||||
overdue?: AlmOverdueRule;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RULE_OPTIONS: { value: AlmRule; label: string; help: string }[] = [
|
|
||||||
{ value: 'fixed', label: 'Fixed', help: 'Always in the same bucket' },
|
|
||||||
{ value: 'maturity', label: 'By due date', help: 'Each account by its own maturity' },
|
|
||||||
{ value: 'split', label: 'Split', help: 'Balance divided by percentages' },
|
|
||||||
{ value: 'behavioural', label: 'By study', help: 'Percentages from the bank’s own study' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Column headings as the filed statement prints them, with the span each one covers. */
|
|
||||||
export const DEFAULT_BUCKETS: AlmBucket[] = [
|
|
||||||
{ label: '1 to 14 days', hint: 'Needed straight away', upTo: { n: 14, unit: 'day' } },
|
|
||||||
{ label: '15 to 28 days', hint: 'After the next two weeks', upTo: { n: 28, unit: 'day' } },
|
|
||||||
{ label: '29 days and upto 3 months', hint: 'Within this quarter', upTo: { n: 3, unit: 'month' } },
|
|
||||||
{ label: 'Over 3 months and upto 6 months', hint: 'Next quarter', upTo: { n: 6, unit: 'month' } },
|
|
||||||
{ label: 'Over 6 months and upto 1 year', hint: 'Within the year', upTo: { n: 1, unit: 'year' } },
|
|
||||||
{ label: 'Over 1 year and upto 3 years', hint: 'Medium term', upTo: { n: 3, unit: 'year' } },
|
|
||||||
{ label: 'Over 3 years and upto 5 years', hint: 'Long term', upTo: { n: 5, unit: 'year' } },
|
|
||||||
{ label: 'Over 5 years', hint: 'Permanent items', upTo: null }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Non-scheduled UCB: negative mismatch in 1-14 and 15-28 days must stay within 20% of outflows. */
|
|
||||||
export const DEFAULT_LIMITS: AlmLimit[] = [
|
|
||||||
{ bucket: 0, pct: 20 },
|
|
||||||
{ bucket: 1, pct: 20 }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Buckets by position, so the lines below read as the bucket they mean. */
|
|
||||||
const B = { d14: 0, d28: 1, m3: 2, m6: 3, y1: 4, y3: 5, y5: 6, over5: 7 };
|
|
||||||
|
|
||||||
const whole = (bucket: number): AlmPlacement[] => [{ bucket, pct: 100 }];
|
|
||||||
|
|
||||||
type Seed = Omit<AlmItem, 'id' | 'spreads' | 'level'> & { level?: 0 | 1; spreads?: boolean };
|
|
||||||
|
|
||||||
const head = (side: AlmSide, ref: string, name: string): Seed => ({
|
|
||||||
side,
|
|
||||||
ref,
|
|
||||||
level: 0,
|
|
||||||
header: true,
|
|
||||||
name,
|
|
||||||
rule: 'fixed',
|
|
||||||
reason: '',
|
|
||||||
placements: []
|
|
||||||
});
|
|
||||||
|
|
||||||
const line = (
|
|
||||||
side: AlmSide,
|
|
||||||
ref: string,
|
|
||||||
name: string,
|
|
||||||
rule: AlmRule,
|
|
||||||
reason: string,
|
|
||||||
placements: AlmPlacement[] = [],
|
|
||||||
level: 0 | 1 = 1,
|
|
||||||
source: AlmSource = ''
|
|
||||||
): Seed => ({ side, ref, level, name, rule, reason, placements, spreads: rule === 'maturity', source });
|
|
||||||
|
|
||||||
const SEED: Seed[] = [
|
|
||||||
// ── OUTFLOWS (liabilities) ──
|
|
||||||
line(
|
|
||||||
'liability',
|
|
||||||
'1.',
|
|
||||||
'Capital',
|
|
||||||
'fixed',
|
|
||||||
'This is the bank’s own money and is never paid back, so it goes in the farthest bucket.',
|
|
||||||
whole(B.over5),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'liability',
|
|
||||||
'2.',
|
|
||||||
'Reserves & Surplus',
|
|
||||||
'fixed',
|
|
||||||
'The bank’s own money, never paid back. Farthest bucket.',
|
|
||||||
whole(B.over5),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
head('liability', '3.', 'Deposits'),
|
|
||||||
line(
|
|
||||||
'liability',
|
|
||||||
'I)',
|
|
||||||
'Current Deposits',
|
|
||||||
'split',
|
|
||||||
'A part can be withdrawn at any time (volatile), so it goes in 1-14 days. The rest is stable (core), so it goes in 1-3 years. Change the % from the bank’s own study.',
|
|
||||||
[
|
|
||||||
{ bucket: B.d14, pct: 10, label: 'Volatile' },
|
|
||||||
{ bucket: B.y3, pct: 90, label: 'Core' }
|
|
||||||
]
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'liability',
|
|
||||||
'II)',
|
|
||||||
'Saving Bank Deposits',
|
|
||||||
'split',
|
|
||||||
'Same as Current. RBI’s benchmark is about 10% volatile. Change the % from the bank’s own study.',
|
|
||||||
[
|
|
||||||
{ bucket: B.d14, pct: 10, label: 'Volatile' },
|
|
||||||
{ bucket: B.y3, pct: 90, label: 'Core' }
|
|
||||||
]
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'liability',
|
|
||||||
'III)',
|
|
||||||
'Term Deposits (FD and RD)',
|
|
||||||
'maturity',
|
|
||||||
'The maturity date of each FD and RD decides its bucket. An FD that is already due goes in the first bucket.',
|
|
||||||
[],
|
|
||||||
1,
|
|
||||||
'FD_RD'
|
|
||||||
),
|
|
||||||
line('liability', 'IV)', 'Certificates of Deposit', 'maturity', 'By maturity date.'),
|
|
||||||
head('liability', '4.', 'Borrowings'),
|
|
||||||
line('liability', 'I.', 'Call and short Notice', 'maturity', 'The bucket of the day it has to be repaid.'),
|
|
||||||
line('liability', 'II.', 'Inter-Bank (Term)', 'maturity', 'The bucket of the day it has to be repaid.'),
|
|
||||||
line('liability', 'III.', 'Refinances', 'maturity', 'The bucket of the day it has to be repaid.'),
|
|
||||||
line('liability', 'IV.', 'Others (specify)', 'maturity', 'The bucket of the day it has to be repaid.'),
|
|
||||||
head('liability', '5.', 'Other Liabilities & Provisions'),
|
|
||||||
line('liability', 'I.', 'DD / Bills Payable', 'fixed', 'These are paid quickly, so 1-14 days.', whole(B.d14)),
|
|
||||||
line('liability', 'II.', 'Branch Adjustments', 'fixed', 'Settled between branches, usually quickly.', whole(B.d14)),
|
|
||||||
line(
|
|
||||||
'liability',
|
|
||||||
'III.',
|
|
||||||
'Provisions',
|
|
||||||
'fixed',
|
|
||||||
'In your filed statement provisions are in 1-14 days. Change it according to the purpose of the provision.',
|
|
||||||
whole(B.d14)
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'liability',
|
|
||||||
'IV.',
|
|
||||||
'Other Liabilities',
|
|
||||||
'fixed',
|
|
||||||
'Amounts that are not to be paid out (like fees received in advance) go in the farthest bucket. Your filed statement has them in over 5 years.',
|
|
||||||
whole(B.over5)
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'liability',
|
|
||||||
'6.',
|
|
||||||
'Unavailed portion of Cash Credit / Overdraft / Demand Loan component of Working Capital',
|
|
||||||
'behavioural',
|
|
||||||
'How much of the limit customers will use comes from the bank’s past history. RBI: fill the % in the buckets within 12 months.',
|
|
||||||
[],
|
|
||||||
0
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'liability',
|
|
||||||
'7.',
|
|
||||||
'Letters of Credit / Guarantees',
|
|
||||||
'behavioural',
|
|
||||||
'How much of the LC / guarantee will devolve comes from the bank’s history, net of margin. Fill in the %.',
|
|
||||||
[],
|
|
||||||
0
|
|
||||||
),
|
|
||||||
line('liability', '8.', 'Repos', 'maturity', 'The bucket of the day it has to be repaid.', [], 0),
|
|
||||||
line('liability', '9.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0),
|
|
||||||
line('liability', '10.', 'Swaps (Sell/Buy/Maturing forwards)', 'maturity', 'By maturity date.', [], 0),
|
|
||||||
line(
|
|
||||||
'liability',
|
|
||||||
'11.',
|
|
||||||
'Interest Payable',
|
|
||||||
'fixed',
|
|
||||||
'Interest still to be paid as per the books. Your filed statement has it in 1-14 days. RBI: the bucket of the date it is due.',
|
|
||||||
whole(B.d14),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'liability',
|
|
||||||
'12.',
|
|
||||||
'Others (specify)',
|
|
||||||
'fixed',
|
|
||||||
'Anything else. Your filed statement has it in over 5 years.',
|
|
||||||
whole(B.over5),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── INFLOWS (assets) ──
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'1.',
|
|
||||||
'Cash',
|
|
||||||
'fixed',
|
|
||||||
'Cash is with the bank right now, so it is available straight away.',
|
|
||||||
whole(B.d14),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'2.',
|
|
||||||
'Balances with RBI',
|
|
||||||
'behavioural',
|
|
||||||
'Balance above the CRR/SLR requirement is in 1-14 days. The required part follows the maturity of the bank’s deposits (DTL). Fill in the %.',
|
|
||||||
[],
|
|
||||||
0
|
|
||||||
),
|
|
||||||
head('asset', '3.', 'Balances with other Banks'),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'I)',
|
|
||||||
'Current Account',
|
|
||||||
'fixed',
|
|
||||||
'What can be withdrawn goes in 1-14 days. RBI: the minimum balance that has to be kept goes in 1-3 years; split it if you want.',
|
|
||||||
whole(B.d14)
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'II)',
|
|
||||||
'Money at Call and short Notice, Term Deposits and other placements with other banks including DCCBs and SCBs',
|
|
||||||
'maturity',
|
|
||||||
'By the maturity date of the FD / call money placed with the bank.'
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'4.',
|
|
||||||
'Investments (including those under Repos but excluding Reverse Repos)',
|
|
||||||
'maturity',
|
|
||||||
'By the maturity of the bond / G-Sec, leaving out the part needed to maintain SLR.',
|
|
||||||
[],
|
|
||||||
0
|
|
||||||
),
|
|
||||||
head('asset', '5.', 'Advances (Performing)'),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'I.',
|
|
||||||
'Bills Purchased and Discounted (including bills under DUPN)',
|
|
||||||
'maturity',
|
|
||||||
'By the due date of the bill.'
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'II.',
|
|
||||||
'Cash Credits, Overdrafts and Loans repayable on demand',
|
|
||||||
'behavioural',
|
|
||||||
'The part that comes back quickly goes in a short bucket; the part that always stays (core) goes in 1-3 years. Fill in the % from the bank’s study.',
|
|
||||||
[],
|
|
||||||
1,
|
|
||||||
'CCOD'
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'III.',
|
|
||||||
'Term Loans',
|
|
||||||
'maturity',
|
|
||||||
'Each EMI goes in the bucket of its own due date, not the whole loan at once.',
|
|
||||||
[],
|
|
||||||
1,
|
|
||||||
'LOAN'
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'6.',
|
|
||||||
'NPAs (Advances and Investments)',
|
|
||||||
'behavioural',
|
|
||||||
'Sub-standard NPA in 3-5 years; doubtful and loss in the farthest bucket (net of provision). Fill in the % as the bank sees fit.',
|
|
||||||
[],
|
|
||||||
0,
|
|
||||||
'NPA'
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'7.',
|
|
||||||
'Fixed Assets',
|
|
||||||
'fixed',
|
|
||||||
'Things like buildings and furniture, which do not bring in money quickly.',
|
|
||||||
whole(B.over5),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
head('asset', '8.', 'Other Assets'),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'I.',
|
|
||||||
'Branch Adjustments',
|
|
||||||
'fixed',
|
|
||||||
'Debit balance of branch adjustments, usually settled quickly.',
|
|
||||||
whole(B.d14)
|
|
||||||
),
|
|
||||||
line('asset', 'II.', 'Leased Assets', 'maturity', 'By the date of the lease’s interim cash flows.'),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'III.',
|
|
||||||
'Others',
|
|
||||||
'fixed',
|
|
||||||
'Things that are not real money (intangibles, non-cash) go in the farthest bucket. Your filed statement has them in over 5 years.',
|
|
||||||
whole(B.over5)
|
|
||||||
),
|
|
||||||
line('asset', '9.', 'Reverse Repos', 'maturity', 'The bucket of the day the money comes back.', [], 0),
|
|
||||||
line('asset', '10.', 'Swaps (Buy/Sell)/Maturing forwards', 'maturity', 'By maturity date.', [], 0),
|
|
||||||
line('asset', '11.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'12.',
|
|
||||||
'Interest Receivable',
|
|
||||||
'fixed',
|
|
||||||
'Interest still to be received. Your filed statement has it in over 5 years. RBI: the bucket of the date it is due.',
|
|
||||||
whole(B.over5),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'13.',
|
|
||||||
'Export Refinance from RBI',
|
|
||||||
'fixed',
|
|
||||||
'Limit that has not been used yet can be drawn straight away.',
|
|
||||||
whole(B.d14),
|
|
||||||
0
|
|
||||||
),
|
|
||||||
line(
|
|
||||||
'asset',
|
|
||||||
'14.',
|
|
||||||
'Others (specify)',
|
|
||||||
'behavioural',
|
|
||||||
'Anything else. Fill in the % as the bank’s study gives.',
|
|
||||||
[],
|
|
||||||
0
|
|
||||||
)
|
|
||||||
];
|
|
||||||
|
|
||||||
/** A fresh, independent copy of the RBI defaults — callers are free to mutate it. */
|
|
||||||
export function defaultConfig(): AlmConfig {
|
|
||||||
return {
|
|
||||||
buckets: DEFAULT_BUCKETS.map((b) => ({ ...b })),
|
|
||||||
limits: DEFAULT_LIMITS.map((l) => ({ ...l })),
|
|
||||||
// RBI (2008 UCB circular): under a month overdue is expected within 14 days; older goes to 29 days-3 months.
|
|
||||||
overdue: { recent: 0, old: 2 },
|
|
||||||
items: SEED.map((s, i) => ({
|
|
||||||
...s,
|
|
||||||
level: s.level ?? 1,
|
|
||||||
id: `d${i + 1}`,
|
|
||||||
spreads: s.spreads ?? false,
|
|
||||||
placements: s.placements.map((p) => ({ ...p }))
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { AlmBucket, AlmSpan } from './alm-bucket-breakdown.config';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The day arithmetic behind the Structural Liquidity Statement: how many days a maturity is from the
|
|
||||||
* as-on date, and which bucket that lands it in. The statement and this screen both use it, so what
|
|
||||||
* the screen shows is what the return will do.
|
|
||||||
*
|
|
||||||
* Dates are plain calendar days ('yyyy-MM-dd'), never timestamps, so the browser's time zone and
|
|
||||||
* daylight saving cannot move a maturity into the next bucket.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const MS_PER_DAY = 86_400_000;
|
|
||||||
|
|
||||||
/** Where the first bucket starts unless told otherwise: the day after the as-on date. */
|
|
||||||
const DAY_ONE: AlmSpan = { n: 1, unit: 'day' };
|
|
||||||
|
|
||||||
const MONTHS = [
|
|
||||||
'Jan',
|
|
||||||
'Feb',
|
|
||||||
'Mar',
|
|
||||||
'Apr',
|
|
||||||
'May',
|
|
||||||
'Jun',
|
|
||||||
'Jul',
|
|
||||||
'Aug',
|
|
||||||
'Sep',
|
|
||||||
'Oct',
|
|
||||||
'Nov',
|
|
||||||
'Dec'
|
|
||||||
];
|
|
||||||
|
|
||||||
/** One bucket laid over a calendar, for a given as-on date. */
|
|
||||||
export interface BucketWindow {
|
|
||||||
label: string;
|
|
||||||
/** First day the bucket takes. */
|
|
||||||
from: Date;
|
|
||||||
/** Last day the bucket takes, or null when it runs on without end. */
|
|
||||||
to: Date | null;
|
|
||||||
/** Days from the as-on date to the first / last day. */
|
|
||||||
fromDays: number;
|
|
||||||
toDays: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 'yyyy-MM-dd' to a local calendar date; null when it is not a real date. */
|
|
||||||
export function parseIsoDate(iso: string | null | undefined): Date | null {
|
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso ?? '');
|
|
||||||
if (!m) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
||||||
return date.getFullYear() === Number(m[1]) && date.getMonth() === Number(m[2]) - 1 && date.getDate() === Number(m[3])
|
|
||||||
? date
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** '30-Jun-2026'. */
|
|
||||||
export function formatDate(date: Date): string {
|
|
||||||
return `${String(date.getDate()).padStart(2, '0')}-${MONTHS[date.getMonth()]}-${date.getFullYear()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whole days from one calendar date to another; negative when `to` is earlier. */
|
|
||||||
export function daysBetween(from: Date, to: Date): number {
|
|
||||||
const a = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
|
|
||||||
const b = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate());
|
|
||||||
return Math.round((b - a) / MS_PER_DAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The date a span reaches. Months and years are calendar months and years, and a day that does not
|
|
||||||
* exist in the target month falls back to that month's last day (31 Jan + 1 month = 28/29 Feb).
|
|
||||||
*/
|
|
||||||
export function addSpan(from: Date, span: AlmSpan): Date {
|
|
||||||
if (span.unit === 'day') {
|
|
||||||
return new Date(from.getFullYear(), from.getMonth(), from.getDate() + span.n);
|
|
||||||
}
|
|
||||||
const months = span.unit === 'year' ? span.n * 12 : span.n;
|
|
||||||
const target = new Date(from.getFullYear(), from.getMonth() + months, 1);
|
|
||||||
const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
|
|
||||||
return new Date(target.getFullYear(), target.getMonth(), Math.min(from.getDate(), lastDay));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Index of the bucket a maturity falls in. A maturity on or before the as-on date is already due, so
|
|
||||||
* it is slotted in the first bucket rather than falling out of the statement.
|
|
||||||
*/
|
|
||||||
export function bucketIndexFor(asOn: Date, maturity: Date, buckets: AlmBucket[]): number {
|
|
||||||
if (!buckets.length || maturity <= asOn) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < buckets.length; i++) {
|
|
||||||
const upTo = buckets[i].upTo;
|
|
||||||
if (!upTo || maturity <= addSpan(asOn, upTo)) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buckets.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Each bucket as a run of calendar days from the as-on date, in bucket order. */
|
|
||||||
export function bucketWindows(asOn: Date, buckets: AlmBucket[]): BucketWindow[] {
|
|
||||||
let from = addSpan(asOn, fromSpanOf(buckets, 0) ?? DAY_ONE);
|
|
||||||
return buckets.map((b) => {
|
|
||||||
const to = b.upTo ? addSpan(asOn, b.upTo) : null;
|
|
||||||
const window: BucketWindow = {
|
|
||||||
label: b.label,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
fromDays: daysBetween(asOn, from),
|
|
||||||
toDays: to ? daysBetween(asOn, to) : null
|
|
||||||
};
|
|
||||||
if (to) {
|
|
||||||
from = new Date(to.getFullYear(), to.getMonth(), to.getDate() + 1);
|
|
||||||
}
|
|
||||||
return window;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 'Days 1 to 14', 'Days 29 to 92', 'Day 1827 onwards'. */
|
|
||||||
export function dayRangeText(w: BucketWindow): string {
|
|
||||||
return w.toDays === null ? `Day ${w.fromDays} onwards` : `Days ${w.fromDays} to ${w.toDays}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── The bucket ranges as a From / To table ──
|
|
||||||
|
|
||||||
/** '14 d', '3 m', '1 y' — how a span is written in the From / To table. */
|
|
||||||
export function spanText(span: AlmSpan): string {
|
|
||||||
return `${span.n} ${span.unit === 'day' ? 'd' : span.unit === 'month' ? 'm' : 'y'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** '14 days', '1 month', '5 years' — a span in words, as the statement's headings put it. */
|
|
||||||
function spanWords(span: AlmSpan): string {
|
|
||||||
const word = span.unit === 'day' ? 'day' : span.unit === 'month' ? 'month' : 'year';
|
|
||||||
return `${span.n} ${word}${span.n === 1 ? '' : 's'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where bucket `index` starts, as it is written in the table: the first starts on day 1, a bucket
|
|
||||||
* after one that ends in days starts the day after (14 d then 15 d), and one after a month or year
|
|
||||||
* starts "over" it (3 m then 3 m). Null for the first bucket, whose start is fixed.
|
|
||||||
*/
|
|
||||||
export function fromSpanOf(buckets: AlmBucket[], index: number): AlmSpan | null {
|
|
||||||
if (index === 0) {
|
|
||||||
return { ...(buckets[0]?.startSpan ?? DAY_ONE) };
|
|
||||||
}
|
|
||||||
const previous = buckets[index - 1].upTo;
|
|
||||||
if (!previous) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return previous.unit === 'day' ? { n: previous.n + 1, unit: 'day' } : { ...previous };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Move the start of bucket `index`, which is the end of the one before it. */
|
|
||||||
export function setFromSpan(buckets: AlmBucket[], index: number, from: AlmSpan): void {
|
|
||||||
const n = Number(from.n) || 1;
|
|
||||||
if (index === 0) {
|
|
||||||
// Day 1 is the ordinary start, so it is stored as no override at all.
|
|
||||||
buckets[0].startSpan = n === 1 && from.unit === 'day' ? undefined : { n, unit: from.unit };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
buckets[index - 1].upTo = from.unit === 'day' ? { n: Math.max(1, n - 1), unit: 'day' } : { n, unit: from.unit };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The column heading a range deserves, worded the way the filed statement words them:
|
|
||||||
* '1 to 14 days', '29 days and upto 3 months', 'Over 3 months and upto 6 months', 'Over 5 years'.
|
|
||||||
*/
|
|
||||||
export function bucketLabel(buckets: AlmBucket[], index: number): string {
|
|
||||||
const to = buckets[index]?.upTo ?? null;
|
|
||||||
const previous = index > 0 ? buckets[index - 1].upTo : null;
|
|
||||||
if (index === 0) {
|
|
||||||
const start = buckets[0].startSpan;
|
|
||||||
if (start) {
|
|
||||||
if (start.unit === 'day') {
|
|
||||||
return !to
|
|
||||||
? `Over ${spanWords(start)}`
|
|
||||||
: to.unit === 'day'
|
|
||||||
? `${start.n} to ${to.n} days`
|
|
||||||
: `${start.n} days and upto ${spanWords(to)}`;
|
|
||||||
}
|
|
||||||
return to ? `Over ${spanWords(start)} and upto ${spanWords(to)}` : `Over ${spanWords(start)}`;
|
|
||||||
}
|
|
||||||
if (to?.unit === 'day' && to.n === 1) {
|
|
||||||
return 'Next day';
|
|
||||||
}
|
|
||||||
return !to ? 'Over 0 days' : to.unit === 'day' ? `1 to ${to.n} days` : `1 day and upto ${spanWords(to)}`;
|
|
||||||
}
|
|
||||||
if (!previous) {
|
|
||||||
return buckets[index].label;
|
|
||||||
}
|
|
||||||
if (previous.unit === 'day') {
|
|
||||||
const from = previous.n + 1;
|
|
||||||
if (!to) {
|
|
||||||
return `Over ${spanWords(previous)}`;
|
|
||||||
}
|
|
||||||
if (to.unit === 'day' && to.n === from) {
|
|
||||||
return `Day ${from}`;
|
|
||||||
}
|
|
||||||
return to.unit === 'day' ? `${from} to ${to.n} days` : `${from} days and upto ${spanWords(to)}`;
|
|
||||||
}
|
|
||||||
return to ? `Over ${spanWords(previous)} and upto ${spanWords(to)}` : `Over ${spanWords(previous)}`;
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { Injectable, inject } from '@angular/core';
|
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
|
|
||||||
/** One product's total, split by bucket, in rupees. */
|
|
||||||
export interface MaturityGroup {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
side: 'liability' | 'asset';
|
|
||||||
accounts: number;
|
|
||||||
total: number;
|
|
||||||
buckets: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loans reported apart from the date buckets, with the reason they are not slotted by date. */
|
|
||||||
export interface NotSlotted {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
accounts: number;
|
|
||||||
outstanding: number;
|
|
||||||
why: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MaturityBreakdown {
|
|
||||||
asOnDate: string;
|
|
||||||
bucketCount: number;
|
|
||||||
groups: MaturityGroup[];
|
|
||||||
notSlotted: NotSlotted[];
|
|
||||||
notes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the screen asks the server for: the date, the bank's own bucket edges and its overdue rule. */
|
|
||||||
export interface MaturityQuery {
|
|
||||||
asOnDate: string;
|
|
||||||
/** Edges like '14d,28d,3m,6m,1y,3y,5y'. */
|
|
||||||
buckets: string;
|
|
||||||
overdueRecentBucket: number;
|
|
||||||
overdueOldBucket: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One account found by a search, to be picked. */
|
|
||||||
export interface AccountHit {
|
|
||||||
/** FD, RD or LOAN. */
|
|
||||||
kind: 'FD' | 'RD' | 'LOAN';
|
|
||||||
id: number;
|
|
||||||
accountNo: string;
|
|
||||||
customer: string | null;
|
|
||||||
product: string;
|
|
||||||
/** Maturity date of a deposit; null for a loan. */
|
|
||||||
date: string | null;
|
|
||||||
/** Deposit balance, or loan principal outstanding. */
|
|
||||||
amount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One deposit, or one unpaid loan instalment, of the accounts picked. */
|
|
||||||
export interface AccountRow {
|
|
||||||
id: number;
|
|
||||||
accountNo: string;
|
|
||||||
customer: string | null;
|
|
||||||
product: string;
|
|
||||||
kind: string;
|
|
||||||
/** Loan instalment number, for term loans. */
|
|
||||||
installment?: number;
|
|
||||||
/** Maturity date of a deposit, or due date of an instalment. */
|
|
||||||
date: string | null;
|
|
||||||
/** Days from the as-on date to that date; negative once it has passed. */
|
|
||||||
days: number | null;
|
|
||||||
status: string;
|
|
||||||
/** Position of the bucket it falls in. */
|
|
||||||
bucket: number | null;
|
|
||||||
amount: number;
|
|
||||||
/** Why the row needs a second look, for an NPA or a cash-credit / overdraft loan. */
|
|
||||||
note?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectedReport {
|
|
||||||
asOnDate: string;
|
|
||||||
bucketCount: number;
|
|
||||||
count: number;
|
|
||||||
total: number;
|
|
||||||
rows: AccountRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fixed-term deposits and loans of the bank's customers, slotted into residual-maturity buckets. */
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class AlmMaturityService {
|
|
||||||
private http = inject(HttpClient);
|
|
||||||
|
|
||||||
getBreakdown(query: MaturityQuery): Observable<MaturityBreakdown> {
|
|
||||||
const params = new HttpParams()
|
|
||||||
.set('asOnDate', query.asOnDate)
|
|
||||||
.set('buckets', query.buckets)
|
|
||||||
.set('overdueRecentBucket', query.overdueRecentBucket)
|
|
||||||
.set('overdueOldBucket', query.overdueOldBucket);
|
|
||||||
return this.http.get<MaturityBreakdown>('/almreturn/maturity-breakdown', { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Find accounts by account number or customer name; at least two characters. A kind of FD, RD or LOAN narrows it. */
|
|
||||||
searchAccounts(q: string, kind: string): Observable<{ results: AccountHit[] }> {
|
|
||||||
let params = new HttpParams().set('q', q);
|
|
||||||
if (kind) {
|
|
||||||
params = params.set('kind', kind);
|
|
||||||
}
|
|
||||||
return this.http.get<{ results: AccountHit[] }>('/almreturn/maturity-breakdown/search', { params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The day-wise breakdown of the picked accounts, given as KIND:id pairs like FD:12,RD:5,LOAN:33. */
|
|
||||||
getSelected(accounts: string, query: MaturityQuery): Observable<SelectedReport> {
|
|
||||||
const params = new HttpParams()
|
|
||||||
.set('accounts', accounts)
|
|
||||||
.set('asOnDate', query.asOnDate)
|
|
||||||
.set('buckets', query.buckets)
|
|
||||||
.set('overdueRecentBucket', query.overdueRecentBucket)
|
|
||||||
.set('overdueOldBucket', query.overdueOldBucket);
|
|
||||||
return this.http.get<SelectedReport>('/almreturn/maturity-breakdown/selected', { params });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
RBI Liabilities Register
|
RBI Liabilities Register
|
||||||
</div>
|
</div>
|
||||||
<h2 class="dialog-title">Demand & Time Liabilities</h2>
|
<h2 class="dialog-title">Demand & Time Liabilities</h2>
|
||||||
<p class="dialog-sub">What the bank owes, and what it must hold against it</p>
|
<p class="dialog-sub">NDTL · CRR/SLR Computation Base · As per RBI Master Circular</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<!-- The close being reported, and under it the close the ratios are charged on. Two dates,
|
<!-- The close being reported, and under it the close the ratios are charged on. Two dates,
|
||||||
@@ -45,39 +45,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- What the liabilities are, and what they come to. Four cards rather than five: the CRR
|
<!-- Summary Cards -->
|
||||||
requirement that used to sit here is on the compliance bar below, beside what is held
|
|
||||||
against it, where it answers a question rather than repeating one. -->
|
|
||||||
<div class="summary-row">
|
<div class="summary-row">
|
||||||
<div class="summary-card sc-demand">
|
<div class="summary-card sc-demand">
|
||||||
<div class="sc-label">Demand liabilities</div>
|
<div class="sc-label">Total Demand Liabilities</div>
|
||||||
<div class="sc-value" [title]="money(totalDemand)">{{ tile(totalDemand) }}</div>
|
<div class="sc-value">{{ crore(totalDemand) }}<span class="cr" *ngIf="totalDemand !== null">Cr</span></div>
|
||||||
<div class="sc-sub">Repayable on demand</div>
|
<div class="sc-sub">Payable on demand</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card sc-time">
|
<div class="summary-card sc-time">
|
||||||
<div class="sc-label">Time liabilities</div>
|
<div class="sc-label">Total Time Liabilities</div>
|
||||||
<div class="sc-value" [title]="money(totalTime)">{{ tile(totalTime) }}</div>
|
<div class="sc-value">{{ crore(totalTime) }}<span class="cr" *ngIf="totalTime !== null">Cr</span></div>
|
||||||
<div class="sc-sub">Repayable on maturity</div>
|
<div class="sc-sub">Payable on maturity</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card sc-asset">
|
<div class="summary-card sc-asset">
|
||||||
<div class="sc-label">Held with other banks</div>
|
<div class="sc-label">Assets with Banking System</div>
|
||||||
<!-- Scaled on the tile, exact on hover: the card is read for order of magnitude, but the
|
<div class="sc-value">
|
||||||
figure behind it should never be more than a pointer away. -->
|
{{ crore(totalBankingAssets) }}<span class="cr" *ngIf="totalBankingAssets !== null">Cr</span>
|
||||||
<div class="sc-value" [title]="money(totalBankingAssets)">
|
|
||||||
{{ tile(totalBankingAssets) }}
|
|
||||||
</div>
|
|
||||||
<!-- These assets net against what the bank OWES other banks, not against the whole base, and
|
|
||||||
the netting floors at zero — so where they exceed what is owed, nothing comes off at all.
|
|
||||||
Said plainly, because "deducted from NDTL" was being printed on a card where no
|
|
||||||
deduction had been made. -->
|
|
||||||
<div class="sc-sub" [class.dn]="bankingAssetsNet">
|
|
||||||
{{ bankingAssetsNet ? 'Reduces the base below' : 'More than we owe banks — base unchanged' }}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sc-change dn">↓ Deducted from NDTL</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="summary-card sc-ndtl">
|
<div class="summary-card sc-ndtl">
|
||||||
<div class="sc-label">NDTL — the base</div>
|
<div class="sc-label">Net Demand & Time Liabilities (NDTL)</div>
|
||||||
<div class="sc-value" [title]="money(ndtl)">{{ tile(ndtl) }}</div>
|
<div class="sc-value">{{ crore(ndtl) }}<span class="cr" *ngIf="ndtl !== null">Cr</span></div>
|
||||||
<div class="sc-sub">Demand + time, net of bank balances</div>
|
<div class="sc-sub">After deducting banking assets</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card sc-crr">
|
||||||
|
<!-- The rate is named from the configuration, not fixed here: a card headed "CRR Required
|
||||||
|
(3%)" while Admin holds 4% states a rate the bank is not on. -->
|
||||||
|
<div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div>
|
||||||
|
<div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div>
|
||||||
|
<div class="sc-sub">SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,33 +94,26 @@
|
|||||||
<span class="tab-pill pill-time">02</span> Time Liabilities
|
<span class="tab-pill pill-time">02</span> Time Liabilities
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" [class.active]="activeTab === 'assets'" (click)="activeTab = 'assets'">
|
<button class="tab-btn" [class.active]="activeTab === 'assets'" (click)="activeTab = 'assets'">
|
||||||
<span class="tab-pill pill-asset">03</span> Held with other banks
|
<span class="tab-pill pill-asset">03</span> Assets with Banking System
|
||||||
</button>
|
</button>
|
||||||
<button class="tab-btn" [class.active]="activeTab === 'slr'" (click)="activeTab = 'slr'">
|
<button class="tab-btn" [class.active]="activeTab === 'slr'" (click)="activeTab = 'slr'">
|
||||||
<span class="tab-pill pill-slr">04</span> SLR assets
|
<span class="tab-pill pill-slr">04</span> SLR — Liquid Assets
|
||||||
</button>
|
|
||||||
<!-- Where the reserve is actually held, and what it holds TODAY. The four tabs above report the
|
|
||||||
fortnight close the return is made up to; this one answers the question a treasury desk
|
|
||||||
asks every morning — as things stand now, are we short, and which account do we cover it
|
|
||||||
into. A close two weeks old cannot say that, because the balances have since moved. -->
|
|
||||||
<button class="tab-btn" [class.active]="activeTab === 'held'" (click)="activeTab = 'held'">
|
|
||||||
<span class="tab-pill pill-held">05</span> Our accounts today
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Demand Liabilities Table -->
|
<!-- Demand Liabilities Table -->
|
||||||
@if (activeTab === 'demand') {
|
@if (activeTab === 'demand') {
|
||||||
<div class="section-desc">Money the bank must repay whenever a customer asks for it.</div>
|
<div class="section-desc">Amounts payable on demand — RBI Schedule II</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sl">#</th>
|
<th class="sl">#</th>
|
||||||
<th>Liability Item</th>
|
<th>Liability Item</th>
|
||||||
<th>Item</th>
|
<th>Account Code</th>
|
||||||
<th class="right">Opening</th>
|
<th class="right">Opening (₹ Cr)</th>
|
||||||
<th class="right">Current</th>
|
<th class="right">Current (₹ Cr)</th>
|
||||||
<th class="right">Change</th>
|
<th class="right">Change (₹ Cr)</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -177,17 +167,17 @@
|
|||||||
|
|
||||||
<!-- Time Liabilities Table -->
|
<!-- Time Liabilities Table -->
|
||||||
@if (activeTab === 'time') {
|
@if (activeTab === 'time') {
|
||||||
<div class="section-desc">Money the bank repays only when the deposit matures.</div>
|
<div class="section-desc">Amounts payable after a specified period — RBI Schedule III</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sl">#</th>
|
<th class="sl">#</th>
|
||||||
<th>Liability Item</th>
|
<th>Liability Item</th>
|
||||||
<th>Item</th>
|
<th>Account Code</th>
|
||||||
<th class="right">Opening</th>
|
<th class="right">Opening (₹ Cr)</th>
|
||||||
<th class="right">Current</th>
|
<th class="right">Current (₹ Cr)</th>
|
||||||
<th class="right">Change</th>
|
<th class="right">Change (₹ Cr)</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -241,20 +231,17 @@
|
|||||||
|
|
||||||
<!-- Assets with Banking System Table -->
|
<!-- Assets with Banking System Table -->
|
||||||
@if (activeTab === 'assets') {
|
@if (activeTab === 'assets') {
|
||||||
<div class="section-desc">
|
<div class="section-desc">Deducted from gross liabilities to arrive at NDTL — RBI Master Circular</div>
|
||||||
What the bank keeps with other banks. This comes off what it owes them — and only off that, so where it is the
|
|
||||||
larger of the two the base is left as it is.
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sl">#</th>
|
<th class="sl">#</th>
|
||||||
<th>Asset Item</th>
|
<th>Asset Item</th>
|
||||||
<th>Item</th>
|
<th>Account Code</th>
|
||||||
<th class="right">Opening</th>
|
<th class="right">Opening (₹ Cr)</th>
|
||||||
<th class="right">Current</th>
|
<th class="right">Current (₹ Cr)</th>
|
||||||
<th class="right">Change</th>
|
<th class="right">Change (₹ Cr)</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -288,12 +275,7 @@
|
|||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3">
|
<td colspan="3">
|
||||||
<strong>Total Assets with Banking System</strong>
|
<strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span>
|
||||||
<span class="deduct-tag">{{
|
|
||||||
bankingAssetsNet
|
|
||||||
? '( Deducted from NDTL )'
|
|
||||||
: '( Netted against item I, which is smaller — no deduction )'
|
|
||||||
}}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="right foot-asset">{{ totalOf(bankingAssets, 'openingValue') }}</td>
|
<td class="right foot-asset">{{ totalOf(bankingAssets, 'openingValue') }}</td>
|
||||||
<td class="right foot-asset">{{ totalOf(bankingAssets, 'currentValue') }}</td>
|
<td class="right foot-asset">{{ totalOf(bankingAssets, 'currentValue') }}</td>
|
||||||
@@ -307,18 +289,20 @@
|
|||||||
|
|
||||||
<!-- SLR - Liquid Assets Table -->
|
<!-- SLR - Liquid Assets Table -->
|
||||||
@if (activeTab === 'slr') {
|
@if (activeTab === 'slr') {
|
||||||
<div class="section-desc">Cash, bank balances and securities the bank holds against its SLR requirement.</div>
|
<div class="section-desc">
|
||||||
|
SLR = (Liquid Assets / NDTL) × 100 — RBI Sec 24(2A) of Banking Regulation Act, 1949
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sl">#</th>
|
<th class="sl">#</th>
|
||||||
<th>Liquid Asset</th>
|
<th>Liquid Asset</th>
|
||||||
<th>Item</th>
|
<th>Account Code</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th class="right">Opening</th>
|
<th class="right">Opening (₹ Cr)</th>
|
||||||
<th class="right">Current</th>
|
<th class="right">Current (₹ Cr)</th>
|
||||||
<th class="right">Change</th>
|
<th class="right">Change (₹ Cr)</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -364,141 +348,119 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- How the ratio is struck. The "× 100" box that stood here was a term whose value was 100 and
|
<!-- SLR Formula -->
|
||||||
whose label was 100 — it took a quarter of the row to say that a fraction is written as a
|
|
||||||
percentage. -->
|
|
||||||
<div class="formula-block slr-formula">
|
<div class="formula-block slr-formula">
|
||||||
<div class="formula-title">How the SLR ratio is worked out</div>
|
<div class="formula-title">SLR Computation — (Liquid Assets / NDTL) × 100</div>
|
||||||
<div class="formula-row">
|
<div class="formula-row">
|
||||||
<div class="formula-term ft-slr">
|
<div class="formula-term ft-slr">
|
||||||
<div class="formula-term-label">Assets we hold</div>
|
<div class="formula-term-label">Liquid Assets</div>
|
||||||
<div class="formula-term-val">{{ money(totalLiquidAssets) }}</div>
|
<div class="formula-term-val">₹13,238.80</div>
|
||||||
<div class="formula-term-sub">{{ liquidAssets.length }} categories</div>
|
<div class="formula-term-sub">6 asset categories</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="formula-op">÷</div>
|
<div class="formula-op">÷</div>
|
||||||
<div class="formula-term ft-ndtl">
|
<div class="formula-term ft-ndtl">
|
||||||
<div class="formula-term-label">NDTL base</div>
|
<div class="formula-term-label">NDTL</div>
|
||||||
<div class="formula-term-val">{{ money(baseNdtl) }}</div>
|
<div class="formula-term-val">₹69,221.30</div>
|
||||||
<div class="formula-term-sub">as at {{ baseDateStr || '—' }}</div>
|
<div class="formula-term-sub">Net Demand & Time</div>
|
||||||
|
</div>
|
||||||
|
<div class="formula-op">×</div>
|
||||||
|
<div class="formula-term" style="border-color: #e2e8f0; background: #f8fafc">
|
||||||
|
<div class="formula-term-label" style="color: #64748b">100</div>
|
||||||
|
<div class="formula-term-val" style="color: #334155">100</div>
|
||||||
|
<div class="formula-term-sub"> </div>
|
||||||
</div>
|
</div>
|
||||||
<div class="formula-op equals">=</div>
|
<div class="formula-op equals">=</div>
|
||||||
<div class="formula-term ft-slr-result">
|
<div class="formula-term ft-slr-result">
|
||||||
<div class="formula-term-label">SLR held</div>
|
<div class="formula-term-label">Actual SLR</div>
|
||||||
<div class="formula-term-val">{{ slrActualPercent }}%</div>
|
<div class="formula-term-val">{{ slrActualPercent }}%</div>
|
||||||
<div class="formula-term-sub">minimum {{ slrRate }}%</div>
|
<div class="formula-term-sub">Min required: {{ slrRate }}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- The SLR compliance bar that used to sit here said the same three figures as the row above
|
<!-- SLR Status Bar -->
|
||||||
it and the bar below it — required, held, surplus, three times on one screen. It is gone;
|
<div class="slr-status-bar">
|
||||||
the compliance bar at the foot carries both ratios for every tab. -->
|
<div class="slr-status-left">
|
||||||
|
<div class="slr-status-title">SLR Compliance Status</div>
|
||||||
|
<div class="slr-status-desc">
|
||||||
|
Actual: {{ slrActualPercent }}% · Required: {{ slrRate }}.00% · NDTL: ₹{{
|
||||||
|
ndtl | number: '1.2-2'
|
||||||
|
}}
|
||||||
|
Cr
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="slr-status-calcs">
|
||||||
|
<div class="slr-calc">
|
||||||
|
<div class="slr-calc-label">SLR Required ({{ slrRate }}%)</div>
|
||||||
|
<div class="slr-calc-val">₹{{ slrRequired | number: '1.2-2' }} Cr</div>
|
||||||
|
</div>
|
||||||
|
<div class="slr-calc">
|
||||||
|
<div class="slr-calc-label">Liquid Assets Held</div>
|
||||||
|
<div class="slr-calc-val">₹{{ totalLiquidAssets | number: '1.2-2' }} Cr</div>
|
||||||
|
</div>
|
||||||
|
<div class="slr-calc">
|
||||||
|
<div class="slr-calc-label">Surplus</div>
|
||||||
|
<div class="slr-calc-val surplus">₹{{ slrSurplus | number: '1.2-2' }} Cr</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Required against held, for both ratios. Always on screen, under whichever tab is open:
|
<!-- NDTL Formula Block -->
|
||||||
whether the bank is short is the one thing a reader must not have to go looking for. -->
|
<div class="formula-block">
|
||||||
<div class="crr-bar">
|
<div class="formula-title">
|
||||||
<div class="crr-text">
|
NDTL Computation — Formula Breakdown · Data as of {{ reportingDateStr }}
|
||||||
<div class="crr-title">Are we meeting the requirement?</div>
|
|
||||||
<div class="crr-desc">
|
|
||||||
Charged on the NDTL of {{ money(baseNdtl) }} as at {{ baseDateStr || reportingDateStr }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Required AND held, side by side. The requirement alone answers half the question: a screen
|
<div class="formula-row">
|
||||||
headed "Reserve Requirements" that states ₹2.10 Cr without saying what is against it sends
|
<div class="formula-term ft-demand">
|
||||||
a reader to another tab to find out whether the bank is short — which is the one thing they
|
<div class="formula-term-label">Demand Liabilities</div>
|
||||||
opened this bar to learn. -->
|
<div class="formula-term-val">₹{{ totalDemand | number: '1.2-2' }}</div>
|
||||||
<div class="crr-calcs">
|
<div class="formula-term-sub">16 line items</div>
|
||||||
@for (r of positionRatios(); track r.name) {
|
</div>
|
||||||
<div class="crr-calc" [class.crr-calc--short]="!r.met">
|
<div class="formula-op">+</div>
|
||||||
<!-- A ratio with no rate on record is said to have none. It used to print "SLR @ null%" and
|
<div class="formula-term ft-time">
|
||||||
a surplus of nothing, which reads as a requirement met rather than one not set. -->
|
<div class="formula-term-label">Time Liabilities</div>
|
||||||
<div class="crr-calc-label">{{ r.name }}{{ r.rate === null ? ' — rate not set' : ' @ ' + r.rate + '%' }}</div>
|
<div class="formula-term-val">₹{{ totalTime | number: '1.2-2' }}</div>
|
||||||
<div class="crr-calc-val">{{ money(r.required) }}</div>
|
<div class="formula-term-sub">13 line items</div>
|
||||||
<div class="crr-calc-held">
|
</div>
|
||||||
held {{ money(r.maintained) }}
|
<div class="formula-op minus">−</div>
|
||||||
<span [class.crr-short]="!r.met">
|
<div class="formula-term ft-asset">
|
||||||
· {{ r.met ? '+' + money(r.surplus) : 'short ' + money(r.shortfall) }}
|
<div class="formula-term-label">Assets with Banking</div>
|
||||||
</span>
|
<div class="formula-term-val">₹{{ totalBankingAssets | number: '1.2-2' }}</div>
|
||||||
</div>
|
<div class="formula-term-sub">3 line items</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
<div class="formula-op equals">=</div>
|
||||||
<div class="crr-calc">
|
<div class="formula-term ft-ndtl">
|
||||||
<div class="crr-calc-label">Total Reserve</div>
|
<div class="formula-term-label">NDTL</div>
|
||||||
<div class="crr-calc-val">{{ money(totalReserve) }}</div>
|
<div class="formula-term-val">₹{{ ndtl | number: '1.2-2' }}</div>
|
||||||
<div class="crr-calc-held">CRR + SLR required</div>
|
<div class="formula-term-sub">CRR & SLR base</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reserve accounts, as they stand today -->
|
<!-- CRR/SLR Bar -->
|
||||||
@if (activeTab === 'held') {
|
<div class="crr-bar">
|
||||||
<div class="section-desc">
|
<div class="crr-text">
|
||||||
Where the cash reserve is held, with each account's balance on the business date
|
<div class="crr-title">CRR & SLR Reserve Requirements — RBI Computation on NDTL</div>
|
||||||
<strong>{{ position?.onDate || '—' }}</strong
|
<div class="crr-desc">
|
||||||
>. The requirement below is charged on <strong>{{ position?.baseDate || '—' }}</strong
|
Based on NDTL of ₹{{ ndtl | number: '1.2-2' }} Cr · Data as of {{ reportingDateStr }} (45-day lag)
|
||||||
>, a fortnight back — RBI's lag is a rule, so today's own liabilities settle nothing yet.
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="crr-calcs">
|
||||||
@if (position) {
|
<div class="crr-calc">
|
||||||
<!-- Required against held, for each ratio. Stated before the accounts because a desk reads
|
<div class="crr-calc-label">CRR @ {{ crrRate }}.00%</div>
|
||||||
the gap first and only then asks which account to move money into. -->
|
<div class="crr-calc-val">₹{{ crrRequired | number: '1.2-2' }} Cr</div>
|
||||||
<div class="held-ratios">
|
|
||||||
@for (r of positionRatios(); track r.name) {
|
|
||||||
<div class="held-ratio" [class.held-ratio--short]="!r.met">
|
|
||||||
<div class="held-ratio__name">{{ r.name }}{{ r.rate === null ? '' : ' @ ' + r.rate + '%' }}</div>
|
|
||||||
<div class="held-ratio__grid">
|
|
||||||
<span>Required</span><strong>{{ money(r.required) }}</strong> <span>Maintained</span
|
|
||||||
><strong>{{ money(r.maintained) }}</strong>
|
|
||||||
<span>{{ r.met ? 'Surplus' : 'Shortfall' }}</span>
|
|
||||||
<strong [class.held-short]="!r.met">{{ money(r.met ? r.surplus : r.shortfall) }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="crr-calc">
|
||||||
<div class="table-wrap">
|
<div class="crr-calc-label">SLR @ {{ slrRate }}.00%</div>
|
||||||
<table>
|
<div class="crr-calc-val">₹{{ slrRequired | number: '1.2-2' }} Cr</div>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="sl">#</th>
|
|
||||||
<th>Account</th>
|
|
||||||
<th>Form 1 head</th>
|
|
||||||
<th class="right">Balance today</th>
|
|
||||||
<th> </th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (a of position.accounts; track a.glAccountId) {
|
|
||||||
<tr>
|
|
||||||
<td class="sl">{{ $index + 1 }}</td>
|
|
||||||
<td>
|
|
||||||
<div class="item-name">{{ a.name }}</div>
|
|
||||||
<div class="item-sub mono">{{ a.glCode }}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ a.item }} · {{ a.head }}
|
|
||||||
<!-- An account the return does not read is named as such. It can still be moved
|
|
||||||
into — the money is real — but adding it to what is maintained would have
|
|
||||||
this screen claim a compliance the filed form does not. -->
|
|
||||||
@if (!a.countsTowardMaintained) {
|
|
||||||
<span class="held-chip">not counted on the return</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="right amt">{{ money(a.balance) }}</td>
|
|
||||||
<td class="right">
|
|
||||||
<button class="held-move" (click)="openTransfer(a)">
|
|
||||||
<fa-icon icon="exchange-alt"></fa-icon>
|
|
||||||
Transfer
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
<div class="crr-calc">
|
||||||
}
|
<div class="crr-calc-label">Total Reserve</div>
|
||||||
|
<div class="crr-calc-val">₹{{ totalReserve | number: '1.2-2' }} Cr</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
|
|||||||
@@ -158,10 +158,7 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
|||||||
// ===== Summary Cards =====
|
// ===== Summary Cards =====
|
||||||
.summary-row {
|
.summary-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
// Five cards only while each can still hold its figure. A seventy-crore NDTL printed to the
|
grid-template-columns: repeat(5, 1fr);
|
||||||
// paise needs room, and fixed fifths cut it off at the card edge — the figures were there and
|
|
||||||
// unreadable. Below that width the row wraps to fewer, wider cards instead.
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 20px 28px;
|
padding: 20px 28px;
|
||||||
}
|
}
|
||||||
@@ -170,12 +167,8 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
|||||||
background: $surface;
|
background: $surface;
|
||||||
border: 1px solid $border;
|
border: 1px solid $border;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
// 18px of side padding on a fifth of the row left the figure barely more than half the card.
|
padding: 16px 18px;
|
||||||
padding: 16px 14px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
// A grid item will not shrink below its content by default, which is how a long figure pushed
|
|
||||||
// its own card wider than its column and off the edge of the dialog.
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: box-shadow 0.2s;
|
transition: box-shadow 0.2s;
|
||||||
|
|
||||||
@@ -217,29 +210,16 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: $ink3;
|
color: $ink3;
|
||||||
font-family: $mono;
|
font-family: $mono;
|
||||||
// Tightened from 1px: on a five-up row the tracking alone pushed "NET DEMAND & TIME LIABILITIES
|
letter-spacing: 1px;
|
||||||
// (NDTL)" onto a third line and squeezed the figure beneath it.
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
// Wraps rather than clipping — a heading that reads "ASSETS WITH BANKIN" names nothing.
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sc-value {
|
.sc-value {
|
||||||
// Sized to the card rather than fixed: a figure that outgrows its box is worse than a smaller
|
font-size: 20px;
|
||||||
// one, because the digits that fall off the right are the ones that distinguish it. Floors at
|
|
||||||
// 14px so a long figure shrinks rather than wraps mid-number.
|
|
||||||
font-size: clamp(14px, 1.45vw, 20px);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $ink;
|
color: $ink;
|
||||||
font-family: $mono;
|
font-family: $mono;
|
||||||
// Figures are read by lining their digits up; a proportional fallback would stagger them.
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
// A rupee figure never breaks across lines — "₹6,35,05," over "967.50" reads as two numbers.
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cr {
|
.cr {
|
||||||
@@ -252,16 +232,12 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
|
|||||||
.sc-change {
|
.sc-change {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
line-height: 1.35;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sc-sub {
|
.sc-sub {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: $ink3;
|
color: $ink3;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
line-height: 1.35;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.up {
|
.up {
|
||||||
@@ -893,112 +869,3 @@ $slr-b: #bae6fd;
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Reserve accounts, as they stand today ── */
|
|
||||||
.pill-held {
|
|
||||||
background: #0b5394;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Required against held, per ratio — read before the accounts, because a desk asks how big the
|
|
||||||
gap is before asking which account to cover it from. */
|
|
||||||
.held-ratios {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px 20px 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.held-ratio {
|
|
||||||
flex: 1 1 260px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-left: 4px solid #137333;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8faf9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A ratio that is not met is the thing this tab exists to surface. */
|
|
||||||
.held-ratio--short {
|
|
||||||
border-left-color: #c5221f;
|
|
||||||
background: #fdf6f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.held-ratio__name {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #5f6368;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.held-ratio__grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr;
|
|
||||||
gap: 4px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
span {
|
|
||||||
color: #5f6368;
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.held-short {
|
|
||||||
color: #c5221f;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* An account the filed return does not read. Named rather than hidden: the money is real and can
|
|
||||||
be moved, but it settles nothing on the form. */
|
|
||||||
.held-chip {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 8px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #fff4e5;
|
|
||||||
color: #b06000;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.held-move {
|
|
||||||
padding: 5px 12px;
|
|
||||||
border: 1px solid #0b5394;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
color: #0b5394;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #0b5394;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* What is held against each requirement, under the requirement itself. */
|
|
||||||
.crr-calc-held {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: $ink3;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A ratio that is not met — on a compliance bar this is the one thing that must not be missed. */
|
|
||||||
.crr-calc--short {
|
|
||||||
border-color: $red;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crr-short,
|
|
||||||
.sc-short {
|
|
||||||
color: $red;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,14 +8,12 @@
|
|||||||
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule, DecimalPipe } from '@angular/common';
|
import { CommonModule, DecimalPipe } from '@angular/common';
|
||||||
import { MatDialog, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatTabsModule } from '@angular/material/tabs';
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
|
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
|
||||||
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
|
||||||
import { ReserveTransferDialogComponent } from './reserve-transfer-dialog.component';
|
|
||||||
|
|
||||||
/** One account under a head, at the base date and the reporting date. */
|
/** One account under a head, at the base date and the reporting date. */
|
||||||
interface CrrSlrRow {
|
interface CrrSlrRow {
|
||||||
@@ -33,47 +31,6 @@ interface CrrSlrRow {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One reserve account, with what it holds on the business date. */
|
|
||||||
interface ReserveAccount {
|
|
||||||
glAccountId: number;
|
|
||||||
glCode: string;
|
|
||||||
name: string;
|
|
||||||
balance: number;
|
|
||||||
/** The Form 1 item the account answers — V, VI(a), VI(b), VI(c). */
|
|
||||||
item: string;
|
|
||||||
head: string;
|
|
||||||
/**
|
|
||||||
* Whether the filed return reads this balance.
|
|
||||||
*
|
|
||||||
* An account the bank holds but has not mapped is still somewhere money can be moved, so it is
|
|
||||||
* listed — but counting it toward what is maintained would have this screen claim a compliance
|
|
||||||
* the return does not.
|
|
||||||
*/
|
|
||||||
countsTowardMaintained: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One ratio's standing: what it needs, what it has, and which way the gap runs. */
|
|
||||||
interface ReserveRatio {
|
|
||||||
required: number;
|
|
||||||
maintained: number;
|
|
||||||
surplus: number;
|
|
||||||
/** Positive — a shortfall is an amount to cover, not a negative surplus. */
|
|
||||||
shortfall: number;
|
|
||||||
met: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The reserve position as it stands on the business date. */
|
|
||||||
interface ReservePosition {
|
|
||||||
onDate: string;
|
|
||||||
/** The close the requirement is charged on — a fortnight back, by RBI's lag rule. */
|
|
||||||
baseDate: string;
|
|
||||||
crrPercentage: number | null;
|
|
||||||
slrPercentage: number | null;
|
|
||||||
crr: ReserveRatio;
|
|
||||||
slr: ReserveRatio;
|
|
||||||
accounts: ReserveAccount[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-crr-slr-dialog',
|
selector: 'mifosx-crr-slr-dialog',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -92,18 +49,8 @@ interface ReservePosition {
|
|||||||
export class CrrSlrDialogComponent implements OnInit {
|
export class CrrSlrDialogComponent implements OnInit {
|
||||||
private dialogRef = inject(MatDialogRef<CrrSlrDialogComponent>);
|
private dialogRef = inject(MatDialogRef<CrrSlrDialogComponent>);
|
||||||
private rbi = inject(RbiCircularsService);
|
private rbi = inject(RbiCircularsService);
|
||||||
private dialog = inject(MatDialog);
|
|
||||||
private auth = inject(AuthenticationService);
|
|
||||||
|
|
||||||
activeTab: 'demand' | 'time' | 'assets' | 'slr' | 'held' = 'demand';
|
activeTab: 'demand' | 'time' | 'assets' | 'slr' = 'demand';
|
||||||
|
|
||||||
/**
|
|
||||||
* The reserve position on the business date — null until it loads, or if it cannot be read.
|
|
||||||
*
|
|
||||||
* Separate from the return the other tabs show: those report a fortnight close, this reports
|
|
||||||
* where the money is now.
|
|
||||||
*/
|
|
||||||
position: ReservePosition | null = null;
|
|
||||||
|
|
||||||
/** The date the figures were struck at, as the return itself reports it. */
|
/** The date the figures were struck at, as the return itself reports it. */
|
||||||
reportingDateStr = '';
|
reportingDateStr = '';
|
||||||
@@ -135,23 +82,7 @@ export class CrrSlrDialogComponent implements OnInit {
|
|||||||
totalDemand: number | null = null;
|
totalDemand: number | null = null;
|
||||||
totalTime: number | null = null;
|
totalTime: number | null = null;
|
||||||
totalBankingAssets: number | null = null;
|
totalBankingAssets: number | null = null;
|
||||||
/**
|
|
||||||
* Whether item III was actually deducted from the base.
|
|
||||||
*
|
|
||||||
* It nets against item I rather than against the whole base, and the netting floors at zero — so
|
|
||||||
* a bank holding more with other banks than it owes them has nothing deducted at all.
|
|
||||||
*/
|
|
||||||
bankingAssetsNet = false;
|
|
||||||
/** Item IV(a) — the bank's own net liabilities on the date being reported. */
|
|
||||||
ndtl: number | null = null;
|
ndtl: number | null = null;
|
||||||
/**
|
|
||||||
* Item IV(b) — the base a fortnight back, which both ratios are actually charged on.
|
|
||||||
*
|
|
||||||
* A separate figure from `ndtl` above and not interchangeable with it: the requirement is struck
|
|
||||||
* on this one, so a ratio or a tile that quotes the other states a percentage of something the
|
|
||||||
* requirement was never computed from.
|
|
||||||
*/
|
|
||||||
baseNdtl: number | null = null;
|
|
||||||
crrRate: number | null = null;
|
crrRate: number | null = null;
|
||||||
slrRate: number | null = null;
|
slrRate: number | null = null;
|
||||||
crrRequired: number | null = null;
|
crrRequired: number | null = null;
|
||||||
@@ -167,7 +98,7 @@ export class CrrSlrDialogComponent implements OnInit {
|
|||||||
* A dash and a zero say different things — "not reported" against "reported as nil" — and on a
|
* A dash and a zero say different things — "not reported" against "reported as nil" — and on a
|
||||||
* regulatory screen the difference decides whether anyone goes looking for the missing mapping.
|
* regulatory screen the difference decides whether anyone goes looking for the missing mapping.
|
||||||
*/
|
*/
|
||||||
rupees(value: number | null): string {
|
crore(value: number | null): string {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
@@ -175,19 +106,9 @@ export class CrrSlrDialogComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Today's position, alongside the return below. The two answer different questions and are
|
// The return is reported in rupees; this screen reads in crore, as a dashboard tile does.
|
||||||
// fetched separately: the return reports a fortnight close, this reports the balances as they
|
const toCrore = (v: number | null | undefined): number | null =>
|
||||||
// stand on the business date, which is what a desk can still act on.
|
v === null || v === undefined ? null : +(v / 1e7).toFixed(2);
|
||||||
this.rbi.getReservePosition().subscribe({
|
|
||||||
next: (data: ReservePosition) => (this.position = data),
|
|
||||||
// A position that cannot be read leaves the other four tabs working. The tab simply shows
|
|
||||||
// nothing rather than taking the whole dialog down with it.
|
|
||||||
error: () => (this.position = null)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Figures stay in rupees. They were rounded to crore until now, which on a bank this size threw
|
|
||||||
// away everything that tells one fortnight from the next — a seventy-crore NDTL and the one a
|
|
||||||
// fortnight later both read "₹70 Cr".
|
|
||||||
|
|
||||||
// The statutory Form 1, not the working report. Form 1 is filed twice a month — at the 15th and
|
// The statutory Form 1, not the working report. Form 1 is filed twice a month — at the 15th and
|
||||||
// at the month end — and this screen should show the close the bank last filed, not a position
|
// at the month end — and this screen should show the close the bank last filed, not a position
|
||||||
@@ -218,66 +139,27 @@ export class CrrSlrDialogComponent implements OnInit {
|
|||||||
// Part A's own lines, read by the Roman numeral each carries. Read from the return rather
|
// Part A's own lines, read by the Roman numeral each carries. Read from the return rather
|
||||||
// than recomputed, so this screen and the filed form cannot come to disagree.
|
// than recomputed, so this screen and the filed form cannot come to disagree.
|
||||||
const lines: any[] = data?.partA?.lines ?? [];
|
const lines: any[] = data?.partA?.lines ?? [];
|
||||||
// Matched without regard to case: the form prints its heads in capitals — "(A) DEMAND
|
|
||||||
// LIABILITIES :" — and matching them as written left every tile reading a dash while the
|
|
||||||
// figures sat in the response.
|
|
||||||
// Matched without regard to case, and only against lines that actually carry figures. The
|
|
||||||
// form heads item I with "(a) Demand Liabilities:" — a caption with no values — directly
|
|
||||||
// above item II's "(A) DEMAND LIABILITIES :", which holds the figure. Matching on text
|
|
||||||
// alone found the caption first and read a dash off it.
|
|
||||||
const at = (startsWith: string): number | null => {
|
const at = (startsWith: string): number | null => {
|
||||||
const want = startsWith.toUpperCase();
|
const line = lines.find((l: any) => String(l?.label ?? '').startsWith(startsWith));
|
||||||
const line = lines.find(
|
|
||||||
(l: any) =>
|
|
||||||
Array.isArray(l?.values) &&
|
|
||||||
String(l?.label ?? '')
|
|
||||||
.toUpperCase()
|
|
||||||
.startsWith(want)
|
|
||||||
);
|
|
||||||
return line?.values?.[latest] ?? null;
|
return line?.values?.[latest] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Item II's own two lines, matched including the trailing colon the form prints. Item I
|
this.totalDemand = toCrore(at('(A) Demand liabilities'));
|
||||||
// carries a "(b) Time Liabilities to the banking system" of its own — a different figure,
|
this.totalTime = toCrore(at('(B) Time liabilities'));
|
||||||
// and the one a looser match found first.
|
this.totalBankingAssets = toCrore(at('TOTAL OF III'));
|
||||||
this.totalDemand = at('(A) DEMAND LIABILITIES :');
|
// IV(b) is the base the ratios are actually charged on — the figure behind the requirement,
|
||||||
this.totalTime = at('(B) TIME LIABILITIES :');
|
// not the bank's liabilities on the day being reported.
|
||||||
this.totalBankingAssets = at('TOTAL OF III');
|
this.ndtl = toCrore(at('IV. b)'));
|
||||||
// Whether item III actually came off the base. The form nets it against item I — the
|
this.crrRequired = toCrore(data?.partB?.required?.[latest]);
|
||||||
// liabilities OWED TO the banking system — and floors that netting at zero: where the
|
this.slrRequired = toCrore(data?.partC?.required?.[latest]);
|
||||||
// bank's assets with other banks exceed what it owes them, the excess is NOT deducted and
|
this.totalLiquidAssets = toCrore(data?.partC?.maintained?.[latest]);
|
||||||
// the base is item II alone. Stating "deducted from NDTL" in that case names a subtraction
|
|
||||||
// the return did not make, and a reader checking the arithmetic finds it does not work.
|
|
||||||
const liabilitiesToBanks = at('TOTAL OF I');
|
|
||||||
this.bankingAssetsNet =
|
|
||||||
liabilitiesToBanks !== null &&
|
|
||||||
this.totalBankingAssets !== null &&
|
|
||||||
liabilitiesToBanks > this.totalBankingAssets;
|
|
||||||
// Two different figures, and the screen needs both.
|
|
||||||
//
|
|
||||||
// IV(a) is the bank's OWN net liabilities on the reporting date — what the demand, time and
|
|
||||||
// banking-asset tiles add up to, so it is the one the formula row must end at. IV(b) is the
|
|
||||||
// base a fortnight back, which is what the requirement is charged on.
|
|
||||||
//
|
|
||||||
// Showing IV(b) as the result of the formula was arithmetic that did not work: three terms
|
|
||||||
// struck on one date reaching a figure struck on another, off by whatever the liabilities
|
|
||||||
// had moved in between.
|
|
||||||
this.ndtl = at('IV. a)');
|
|
||||||
this.baseNdtl = at('IV. b)');
|
|
||||||
this.crrRequired = data?.partB?.required?.[latest] ?? null;
|
|
||||||
this.slrRequired = data?.partC?.required?.[latest] ?? null;
|
|
||||||
this.totalLiquidAssets = data?.partC?.maintained?.[latest] ?? null;
|
|
||||||
|
|
||||||
if (this.slrRequired !== null && this.totalLiquidAssets !== null) {
|
if (this.slrRequired !== null && this.totalLiquidAssets !== null) {
|
||||||
this.slrSurplus = +(this.totalLiquidAssets - this.slrRequired).toFixed(2);
|
this.slrSurplus = +(this.totalLiquidAssets - this.slrRequired).toFixed(2);
|
||||||
}
|
}
|
||||||
// Struck on the base, not on today's own liabilities: the requirement below it is charged
|
if (this.ndtl) {
|
||||||
// on the base, and a ratio measured against a different denominator from the one the
|
|
||||||
// requirement uses cannot be compared with it — "33.62% against a minimum of 18%" would be
|
|
||||||
// two percentages of two different things.
|
|
||||||
if (this.baseNdtl) {
|
|
||||||
this.slrActualPercent =
|
this.slrActualPercent =
|
||||||
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.baseNdtl) * 100).toFixed(2);
|
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.ndtl) * 100).toFixed(2);
|
||||||
}
|
}
|
||||||
if (this.crrRequired !== null && this.slrRequired !== null) {
|
if (this.crrRequired !== null && this.slrRequired !== null) {
|
||||||
this.totalReserve = +(this.crrRequired + this.slrRequired).toFixed(2);
|
this.totalReserve = +(this.crrRequired + this.slrRequired).toFixed(2);
|
||||||
@@ -327,20 +209,13 @@ export class CrrSlrDialogComponent implements OnInit {
|
|||||||
// The form's own wording, with its numbering trimmed off the front — the table has a column
|
// The form's own wording, with its numbering trimmed off the front — the table has a column
|
||||||
// of its own for that.
|
// of its own for that.
|
||||||
name: label.replace(/^\s*\(?[a-zA-Z0-9]+\)?[.)]?\s*/, '').trim() || label.trim(),
|
name: label.replace(/^\s*\(?[a-zA-Z0-9]+\)?[.)]?\s*/, '').trim() || label.trim(),
|
||||||
// The form's own numbering — I(a)(ii), II(A), VII(c) — rather than the configuration key
|
sub: line.configKey ? String(line.configKey).replace(/_/g, ' ') : 'Derived from the form',
|
||||||
// behind it. A reader checking this screen against the filed return looks for the item
|
code: line.configKey ?? '—',
|
||||||
// number; FORM1_I_A_II_OTHER_DEMAND_BS is the name of a database row and means nothing to
|
opening: this.crore(opening),
|
||||||
// them.
|
current: this.crore(current),
|
||||||
sub: '',
|
|
||||||
code: (label.match(/^\s*\(?([IVXivx]+|[A-Za-z0-9]+)\)?[.)]?/) ?? [
|
|
||||||
,
|
|
||||||
'—'
|
|
||||||
])[1] ?? '—',
|
|
||||||
opening: this.rupees(opening),
|
|
||||||
current: this.rupees(current),
|
|
||||||
openingValue: opening,
|
openingValue: opening,
|
||||||
currentValue: current,
|
currentValue: current,
|
||||||
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.rupees(change).replace('₹', '')}`,
|
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`,
|
||||||
changeDir: change === null ? 'flat' : change >= 0 ? 'up' : 'dn',
|
changeDir: change === null ? 'flat' : change >= 0 ? 'up' : 'dn',
|
||||||
// A head with no GL mapped reports nothing, and the form says so rather than reporting nil.
|
// A head with no GL mapped reports nothing, and the form says so rather than reporting nil.
|
||||||
status: line.configured === false ? 'Not mapped' : 'Active'
|
status: line.configured === false ? 'Not mapped' : 'Active'
|
||||||
@@ -360,7 +235,7 @@ export class CrrSlrDialogComponent implements OnInit {
|
|||||||
*/
|
*/
|
||||||
totalOf(rows: CrrSlrRow[], column: 'openingValue' | 'currentValue'): string {
|
totalOf(rows: CrrSlrRow[], column: 'openingValue' | 'currentValue'): string {
|
||||||
const values = rows.map((r) => r[column]).filter((v): v is number => v !== null);
|
const values = rows.map((r) => r[column]).filter((v): v is number => v !== null);
|
||||||
return values.length ? this.rupees(values.reduce((a, b) => a + b, 0)) : '—';
|
return values.length ? this.crore(values.reduce((a, b) => a + b, 0)) : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The movement across a tab, between the two closes the form reports. */
|
/** The movement across a tab, between the two closes the form reports. */
|
||||||
@@ -371,7 +246,7 @@ export class CrrSlrDialogComponent implements OnInit {
|
|||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
const change = current.reduce((a, b) => a + b, 0) - opening.reduce((a, b) => a + b, 0);
|
const change = current.reduce((a, b) => a + b, 0) - opening.reduce((a, b) => a + b, 0);
|
||||||
return `${change >= 0 ? '+' : ''}${this.rupees(change).replace('₹', '')}`;
|
return `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fills the four tabs from one reading of the form. */
|
/** Fills the four tabs from one reading of the form. */
|
||||||
@@ -382,148 +257,16 @@ export class CrrSlrDialogComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
// Matched on how the form labels each head, not on a fixed list of account names: a bank that
|
// Matched on how the form labels each head, not on a fixed list of account names: a bank that
|
||||||
// maps a new account to a head gets it on the right tab without this screen being changed.
|
// maps a new account to a head gets it on the right tab without this screen being changed.
|
||||||
// Item II only — what the bank owes its own depositors. Item I's lines are worded the same way
|
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit'));
|
||||||
// but are owed to the banking system, and the form nets them against item III rather than
|
this.timeLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'time liabilit') && !has(l, 'demand and time'));
|
||||||
// adding them here, so a tab headed "payable on demand" that carried both would double-count
|
// III — what the banking system owes this bank, which nets off the liability base.
|
||||||
// the one and misstate the other.
|
this.bankingAssets = this.rowsFrom(lines, latest, (l) =>
|
||||||
const toBankingSystem = (l: string): boolean => has(l, 'to the banking system', 'banking system');
|
has(l, 'current account balances with sbi', 'balances in all accounts', 'money at call', 'advances and any other')
|
||||||
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit') && !toBankingSystem(l));
|
|
||||||
this.timeLiabilities = this.rowsFrom(
|
|
||||||
lines,
|
|
||||||
latest,
|
|
||||||
(l) => has(l, 'time liabilit') && !has(l, 'demand and time') && !toBankingSystem(l)
|
|
||||||
);
|
);
|
||||||
// III — what the banking system owes this bank, which nets off the liability base. Matched on
|
// V, VI and VII — the cash, balances and securities the two ratios are met in.
|
||||||
// the words the form actually prints: its item III(a) reads "TOTAL OF CREDIT BALANCES IN
|
this.liquidAssets = this.rowsFrom(lines, latest, (l) =>
|
||||||
// CURRENT ACCOUNT maintained with S.B.I...", which the shorter phrases this looked for never
|
has(l, 'cash on hand', 'reserve bank of india', 'co-operative bank', 'government securities')
|
||||||
// appeared in, so the tab came out empty while its total showed a figure.
|
|
||||||
this.bankingAssets = this.rowsFrom(
|
|
||||||
lines,
|
|
||||||
latest,
|
|
||||||
(l) =>
|
|
||||||
has(
|
|
||||||
l,
|
|
||||||
'credit balances in current account',
|
|
||||||
'other assets with the banking system',
|
|
||||||
'balances in all accounts',
|
|
||||||
'money at call',
|
|
||||||
'advances & any other'
|
|
||||||
) &&
|
|
||||||
// Item I(a)(i) is worded almost identically but is the opposite side of the ledger: what
|
|
||||||
// the banking system holds WITH this bank, a liability. On a tab headed "deducted from
|
|
||||||
// gross liabilities" it would be read as an asset and netted the wrong way.
|
|
||||||
!has(l, 'maintained with the co-operative bank by state')
|
|
||||||
);
|
);
|
||||||
// V, VI and VII — the cash, balances and securities the two ratios are met in. Item I's own
|
|
||||||
// SBI line is excluded: it is a liability TO the banking system, and listing it among the
|
|
||||||
// assets held against a ratio reads as something the bank can count toward it.
|
|
||||||
this.liquidAssets = this.rowsFrom(
|
|
||||||
lines,
|
|
||||||
latest,
|
|
||||||
(l) =>
|
|
||||||
has(l, 'cash on hand', 'reserve bank of india', 'co.op.bank', 'co-operative bank', 'govt. security') &&
|
|
||||||
!has(l, 'maintained with the co-operative bank by state')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The two ratios as rows, so the template states them once rather than twice over.
|
|
||||||
*/
|
|
||||||
positionRatios(): {
|
|
||||||
name: string;
|
|
||||||
rate: number | null;
|
|
||||||
required: number;
|
|
||||||
maintained: number;
|
|
||||||
surplus: number;
|
|
||||||
shortfall: number;
|
|
||||||
met: boolean;
|
|
||||||
}[] {
|
|
||||||
if (!this.position) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{ name: 'CRR', rate: this.position.crrPercentage, ...this.position.crr },
|
|
||||||
{ name: 'SLR', rate: this.position.slrPercentage, ...this.position.slr }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A reserve figure in rupees, to the paisa.
|
|
||||||
*
|
|
||||||
* Not rounded like the table cells above: a desk covering a shortfall transfers an exact amount, and
|
|
||||||
* "₹0.37 Cr" is not an amount anyone can send.
|
|
||||||
*/
|
|
||||||
money(value: number | null | undefined): string {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
return `₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A figure for a summary tile, scaled so it fits however large the bank grows.
|
|
||||||
*
|
|
||||||
* Tiles are read for order of magnitude, so they carry whole rupees up to a crore and switch to
|
|
||||||
* crore above it: ₹70,10,45,062 is nineteen characters and overflows its card, where ₹70.10 Cr
|
|
||||||
* is seven and says the same thing to anyone glancing at it. The exact amount, to the paise, is
|
|
||||||
* on the tables below and on the reserve-accounts tab — which is where anyone acting on a figure
|
|
||||||
* rather than scanning it goes.
|
|
||||||
*
|
|
||||||
* Scaled rather than merely shrunk, because a fixed format only ever fits the balances the bank
|
|
||||||
* happens to hold today. This one holds at four or five characters whether the figure is lakhs
|
|
||||||
* or lakh-crore, so a bank that grows ten-fold does not come back to a row of clipped tiles.
|
|
||||||
*/
|
|
||||||
tile(value: number | null | undefined): string {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const abs = Math.abs(value);
|
|
||||||
const sign = value < 0 ? '-' : '';
|
|
||||||
// Indian scale: a crore is 1e7, a lakh 1e5. Below a lakh the whole rupee figure is already
|
|
||||||
// short enough that scaling it would hide detail for nothing.
|
|
||||||
if (abs >= 1e7) {
|
|
||||||
return `${sign}₹${(abs / 1e7).toLocaleString('en-IN', { maximumFractionDigits: 2 })} Cr`;
|
|
||||||
}
|
|
||||||
if (abs >= 1e5) {
|
|
||||||
return `${sign}₹${(abs / 1e5).toLocaleString('en-IN', { maximumFractionDigits: 2 })} L`;
|
|
||||||
}
|
|
||||||
return `${sign}₹${Math.round(abs).toLocaleString('en-IN')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move money into or out of a reserve account.
|
|
||||||
*
|
|
||||||
* The entry posts through the platform's own journal command, so it goes to maker-checker like
|
|
||||||
* any other entry against the ledger — the money is real, and a transfer that settles a statutory
|
|
||||||
* requirement is not a thing one person should be able to post alone.
|
|
||||||
*/
|
|
||||||
openTransfer(account: ReserveAccount): void {
|
|
||||||
const ref = this.dialog.open(ReserveTransferDialogComponent, {
|
|
||||||
data: {
|
|
||||||
from: account,
|
|
||||||
// Only the reserve accounts. A transfer out of the cash reserve into something that does
|
|
||||||
// not count toward it would quietly reduce what is maintained, and this screen exists to
|
|
||||||
// keep that figure up.
|
|
||||||
accounts: this.position?.accounts ?? [],
|
|
||||||
// The entry posts against the user's own office — the one whose books hold these accounts.
|
|
||||||
// Falls back to head office, which Fineract seeds as 1, rather than failing to open.
|
|
||||||
officeId: this.auth.getCredentials()?.officeId ?? 1,
|
|
||||||
currencyCode: 'INR'
|
|
||||||
},
|
|
||||||
autoFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
ref.afterClosed().subscribe((posted: boolean) => {
|
|
||||||
if (posted) {
|
|
||||||
// The balances on screen are now a step behind the ledger. Read again rather than adjusting
|
|
||||||
// them here: the entry may have gone to maker-checker and not yet moved anything, and a
|
|
||||||
// screen that assumed it had would show a position the bank is not in.
|
|
||||||
this.rbi.getReservePosition().subscribe({
|
|
||||||
next: (data: ReservePosition) => (this.position = data),
|
|
||||||
error: () => undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="rt">
|
|
||||||
<div class="rt-head">
|
|
||||||
<h2 class="rt-title">Transfer between reserve accounts</h2>
|
|
||||||
<button class="rt-x" (click)="close()" aria-label="Close">
|
|
||||||
<fa-icon icon="times"></fa-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- The two accounts, the way a desk thinks about them: money leaves one and arrives in the
|
|
||||||
other. The ledger's debit and credit are worked out when the entry posts, because asking a
|
|
||||||
treasury user which side to credit in order to move their own money is asking them to do the
|
|
||||||
ledger's job. -->
|
|
||||||
<div class="rt-from">
|
|
||||||
<div class="rt-field-label">From</div>
|
|
||||||
<div class="rt-acct">
|
|
||||||
<div class="rt-acct-name">{{ from.name }}</div>
|
|
||||||
<div class="rt-acct-sub">{{ from.glCode }} · {{ from.item }} {{ from.head }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="rt-bal">
|
|
||||||
<span class="rt-bal-label">Balance</span>
|
|
||||||
<span class="rt-bal-val">{{ money(from.balance) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rt-arrow"><fa-icon icon="arrow-down"></fa-icon></div>
|
|
||||||
|
|
||||||
<div class="rt-row">
|
|
||||||
<label class="rt-field-label" for="rt-to">To</label>
|
|
||||||
<select id="rt-to" class="rt-input" [(ngModel)]="toAccountId">
|
|
||||||
<option [ngValue]="null">Choose the account to receive it</option>
|
|
||||||
@for (a of destinations; track a.glAccountId) {
|
|
||||||
<option [ngValue]="a.glAccountId">{{ a.glCode }} · {{ a.name }} — {{ money(a.balance) }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rt-row">
|
|
||||||
<label class="rt-field-label" for="rt-amt">Amount to transfer</label>
|
|
||||||
<div class="rt-amount-wrap">
|
|
||||||
<span class="rt-rupee">₹</span>
|
|
||||||
<input
|
|
||||||
id="rt-amt"
|
|
||||||
class="rt-input rt-amount"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
placeholder="0.00"
|
|
||||||
[(ngModel)]="amount"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<!-- An account cannot send more than it holds: the ledger would carry a negative balance on a
|
|
||||||
bank account, which is not a position that exists. Said as the amount is typed rather than
|
|
||||||
on submit, so the figure is corrected before anyone commits to it. -->
|
|
||||||
@if (overdrawn) {
|
|
||||||
<div class="rt-warn">More than {{ from.name }} holds. It has {{ money(from.balance) }}.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- What each account would hold once this posts. A transfer is made to reach a balance, so the
|
|
||||||
balance it reaches is the thing worth showing before it is sent. -->
|
|
||||||
@if (to && (amount ?? 0) > 0 && !overdrawn) {
|
|
||||||
<div class="rt-after">
|
|
||||||
<div class="rt-after-row">
|
|
||||||
<span>{{ from.name }}</span>
|
|
||||||
<span
|
|
||||||
>{{ money(from.balance) }} → <strong>{{ money(fromAfter) }}</strong></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="rt-after-row">
|
|
||||||
<span>{{ to.name }}</span>
|
|
||||||
<span
|
|
||||||
>{{ money(to.balance) }} → <strong>{{ money(toAfter) }}</strong></span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="rt-row">
|
|
||||||
<label class="rt-field-label" for="rt-ref">Reference <span class="rt-opt">optional</span></label>
|
|
||||||
<input id="rt-ref" class="rt-input" type="text" placeholder="NEFT / RTGS reference" [(ngModel)]="reference" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rt-row">
|
|
||||||
<label class="rt-field-label" for="rt-note">Note <span class="rt-opt">optional</span></label>
|
|
||||||
<input
|
|
||||||
id="rt-note"
|
|
||||||
class="rt-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="Why the money is being moved"
|
|
||||||
[(ngModel)]="comments"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- What this does and does not do. The entry is the bank's record of the movement; the money
|
|
||||||
reaches the other bank when the desk sends the NEFT or RTGS. A screen that posted silently
|
|
||||||
would leave someone believing the funds had moved. -->
|
|
||||||
<div class="rt-note">
|
|
||||||
<fa-icon icon="info-circle"></fa-icon>
|
|
||||||
This records the movement in the ledger and goes for approval. Send the NEFT or RTGS separately — the money
|
|
||||||
does not move until you do.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (error) {
|
|
||||||
<div class="rt-error">
|
|
||||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="rt-actions">
|
|
||||||
<button mat-button (click)="close()">Cancel</button>
|
|
||||||
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
|
|
||||||
{{ submitting ? 'Posting…' : 'Post transfer' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
$ink: #202124;
|
|
||||||
$ink2: #3c4043;
|
|
||||||
$ink3: #5f6368;
|
|
||||||
$border: #e2e8f0;
|
|
||||||
$primary: #0b5394;
|
|
||||||
$red: #c5221f;
|
|
||||||
$amber: #b06000;
|
|
||||||
$surface: #fff;
|
|
||||||
$mono: 'Roboto Mono', 'Courier New', monospace;
|
|
||||||
|
|
||||||
.rt {
|
|
||||||
padding: 20px 24px 16px;
|
|
||||||
min-width: 420px;
|
|
||||||
max-width: 520px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 17px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $ink;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-x {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: $ink3;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 6px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $ink;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-field-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.4px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: $ink3;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-opt {
|
|
||||||
font-weight: 400;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: 0;
|
|
||||||
color: $ink3;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The source account — fixed, since the dialog is opened from its row, so it reads as a statement
|
|
||||||
// rather than as another field to fill in.
|
|
||||||
.rt-from {
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid $border;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-acct-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $ink;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-acct-sub {
|
|
||||||
font-size: 11px;
|
|
||||||
color: $ink3;
|
|
||||||
font-family: $mono;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-bal {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: baseline;
|
|
||||||
margin-top: 8px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px dashed $border;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-bal-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: $ink3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-bal-val {
|
|
||||||
font-family: $mono;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $ink;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-arrow {
|
|
||||||
text-align: center;
|
|
||||||
color: $primary;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-row {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-input {
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 9px 11px;
|
|
||||||
border: 1px solid $border;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: $ink2;
|
|
||||||
background: $surface;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: $primary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The amount carries the rupee inside the field, so the figure is read as money without the sign
|
|
||||||
// being something anyone has to type.
|
|
||||||
.rt-amount-wrap {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-rupee {
|
|
||||||
position: absolute;
|
|
||||||
left: 11px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
color: $ink3;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-amount {
|
|
||||||
padding-left: 26px;
|
|
||||||
font-family: $mono;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-warn {
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: $red;
|
|
||||||
}
|
|
||||||
|
|
||||||
// What each account would hold once this posts — a transfer is made to reach a balance, so the
|
|
||||||
// balance it reaches belongs on screen before it is sent.
|
|
||||||
.rt-after {
|
|
||||||
padding: 10px 14px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
border: 1px solid #cfe3d4;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f1f8f3;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-after-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
color: $ink2;
|
|
||||||
|
|
||||||
& + & {
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
|
||||||
font-family: $mono;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the entry does and does not do: the money moves when the desk sends the NEFT, not when this
|
|
||||||
// posts, and a dialog that stayed silent would leave someone believing otherwise.
|
|
||||||
.rt-note {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff8e6;
|
|
||||||
color: $amber;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-error {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fdecea;
|
|
||||||
color: $red;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rt-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
||||||
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
|
|
||||||
|
|
||||||
/** A reserve account as the position screen lists it. */
|
|
||||||
export interface TransferAccount {
|
|
||||||
glAccountId: number;
|
|
||||||
glCode: string;
|
|
||||||
name: string;
|
|
||||||
balance: number;
|
|
||||||
item: string;
|
|
||||||
head: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the dialog is opened with: the account clicked, and everything it may move to. */
|
|
||||||
export interface ReserveTransferData {
|
|
||||||
from: TransferAccount;
|
|
||||||
accounts: TransferAccount[];
|
|
||||||
/** The office the entry posts against, and the date it posts on. */
|
|
||||||
officeId: number;
|
|
||||||
currencyCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves money between the accounts the cash reserve is held in.
|
|
||||||
*
|
|
||||||
* The entry posts through the platform's own journal command, so it goes to maker-checker like any
|
|
||||||
* other entry against the ledger. That is deliberate: the amounts settle a statutory requirement,
|
|
||||||
* and a transfer that changes whether the bank is compliant is not something one person should be
|
|
||||||
* able to post alone.
|
|
||||||
*
|
|
||||||
* It records the movement; it does not make it. The money reaches the other bank when the treasury
|
|
||||||
* desk sends the NEFT or RTGS, and this entry is the bank's own record of having done so — which is
|
|
||||||
* why the dialog says as much before it posts.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-reserve-transfer-dialog',
|
|
||||||
standalone: true,
|
|
||||||
templateUrl: './reserve-transfer-dialog.component.html',
|
|
||||||
styleUrls: ['./reserve-transfer-dialog.component.scss'],
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
MatDialogModule,
|
|
||||||
MatButtonModule,
|
|
||||||
FaIconComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ReserveTransferDialogComponent {
|
|
||||||
private dialogRef = inject(MatDialogRef<ReserveTransferDialogComponent>);
|
|
||||||
private rbi = inject(RbiCircularsService);
|
|
||||||
readonly data: ReserveTransferData = inject(MAT_DIALOG_DATA);
|
|
||||||
|
|
||||||
/** The account money leaves. Fixed — the dialog is opened from its row. */
|
|
||||||
readonly from: TransferAccount;
|
|
||||||
/** Everything it can move to: the other reserve accounts, never itself. */
|
|
||||||
readonly destinations: TransferAccount[];
|
|
||||||
|
|
||||||
toAccountId: number | null = null;
|
|
||||||
amount: number | null = null;
|
|
||||||
reference = '';
|
|
||||||
comments = '';
|
|
||||||
|
|
||||||
submitting = false;
|
|
||||||
error = '';
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.from = this.data.from;
|
|
||||||
this.destinations = (this.data.accounts ?? []).filter((a) => a.glAccountId !== this.data.from.glAccountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The account chosen to receive the money, while one is chosen. */
|
|
||||||
get to(): TransferAccount | null {
|
|
||||||
return this.destinations.find((a) => a.glAccountId === this.toAccountId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the source would hold once this posts — the figure that decides whether it may. */
|
|
||||||
get fromAfter(): number {
|
|
||||||
return this.from.balance - (this.amount ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get toAfter(): number {
|
|
||||||
return (this.to?.balance ?? 0) + (this.amount ?? 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the amount can actually be sent.
|
|
||||||
*
|
|
||||||
* An account cannot send more than it holds: the ledger would carry a negative balance on a cash
|
|
||||||
* or bank account, which is not a position that exists.
|
|
||||||
*/
|
|
||||||
get overdrawn(): boolean {
|
|
||||||
return this.amount !== null && this.amount > this.from.balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
get canSubmit(): boolean {
|
|
||||||
return !this.submitting && this.toAccountId !== null && (this.amount ?? 0) > 0 && !this.overdrawn;
|
|
||||||
}
|
|
||||||
|
|
||||||
money(value: number | null | undefined): string {
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
return `₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Posts the transfer as a single journal entry.
|
|
||||||
*
|
|
||||||
* These are asset accounts, so the one money LEAVES is credited and the one it arrives in is
|
|
||||||
* debited — the opposite of how the screen words it, and deliberately so: a treasury user thinks
|
|
||||||
* "send from here, receive there", and making them think in debits and credits to move their own
|
|
||||||
* money would be asking them to do the ledger's job.
|
|
||||||
*/
|
|
||||||
submit(): void {
|
|
||||||
if (!this.canSubmit || !this.to) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.submitting = true;
|
|
||||||
this.error = '';
|
|
||||||
|
|
||||||
// The reserve endpoint, not the manual journal entry one: that is turned off at the HTTP edge
|
|
||||||
// on this installation. The sides are named from and to, as a desk thinks of them; the debit
|
|
||||||
// and credit are worked out on the server, where the accounts can also be checked.
|
|
||||||
const transfer = {
|
|
||||||
fromGlAccountId: this.from.glAccountId,
|
|
||||||
toGlAccountId: this.to.glAccountId,
|
|
||||||
amount: this.amount as number,
|
|
||||||
officeId: this.data.officeId,
|
|
||||||
reference: this.reference || undefined,
|
|
||||||
comments:
|
|
||||||
this.comments || `Reserve transfer: ${this.from.glCode} ${this.from.name} to ${this.to.glCode} ${this.to.name}`
|
|
||||||
};
|
|
||||||
|
|
||||||
this.rbi.transferReserve(transfer).subscribe({
|
|
||||||
next: () => this.dialogRef.close(true),
|
|
||||||
error: (err: any) => {
|
|
||||||
this.submitting = false;
|
|
||||||
// The platform's own message, not a rewritten one: it names the rule that stopped the
|
|
||||||
// entry — a closed accounting period, a disabled account — and a generic "could not post"
|
|
||||||
// sends the user to look for a fault that has already been explained.
|
|
||||||
this.error =
|
|
||||||
err?.error?.developerMessage ||
|
|
||||||
err?.error?.defaultUserMessage ||
|
|
||||||
err?.error?.errors?.[0]?.defaultUserMessage ||
|
|
||||||
'The transfer could not be posted.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
this.dialogRef.close(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -38,18 +38,12 @@
|
|||||||
>
|
>
|
||||||
<span class="metric-value">{{ crrSlr.crrRequired }}</span>
|
<span class="metric-value">{{ crrSlr.crrRequired }}</span>
|
||||||
</div>
|
</div>
|
||||||
@if (crrSlr.crrGap) {
|
|
||||||
<div class="metric-gap" [class.metric-gap--short]="crrSlr.crrGap.short">{{ crrSlr.crrGap.text }}</div>
|
|
||||||
}
|
|
||||||
<div class="metric-row">
|
<div class="metric-row">
|
||||||
<span class="metric-label"
|
<span class="metric-label"
|
||||||
>SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span
|
>SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span
|
||||||
>
|
>
|
||||||
<span class="metric-value">{{ crrSlr.slrRequired }}</span>
|
<span class="metric-value">{{ crrSlr.slrRequired }}</span>
|
||||||
</div>
|
</div>
|
||||||
@if (crrSlr.slrGap) {
|
|
||||||
<div class="metric-gap" [class.metric-gap--short]="crrSlr.slrGap.short">{{ crrSlr.slrGap.text }}</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<!-- "Within Limits" is a compliance statement, so it is made only where the return actually
|
<!-- "Within Limits" is a compliance statement, so it is made only where the return actually
|
||||||
says so. Where it cannot compute, the card says that instead of passing the bank. -->
|
says so. Where it cannot compute, the card says that instead of passing the bank. -->
|
||||||
|
|||||||
@@ -899,17 +899,3 @@ $purple: #7c3aed;
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// What is held against a requirement, under the requirement itself. Surplus is quiet; a shortfall is
|
|
||||||
// red, because on a compliance card it is the one line that must not be read past.
|
|
||||||
.metric-gap {
|
|
||||||
margin: -2px 0 8px;
|
|
||||||
text-align: right;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #137333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-gap--short {
|
|
||||||
color: #c5221f;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -61,19 +61,8 @@ export class DashboardComponent implements OnInit {
|
|||||||
slrRate: number | null;
|
slrRate: number | null;
|
||||||
crrRequired: string;
|
crrRequired: string;
|
||||||
slrRequired: string;
|
slrRequired: string;
|
||||||
/** Surplus, or the shortfall, per ratio. A shortfall is its own amount, not a negative surplus. */
|
|
||||||
crrGap: { text: string; short: boolean } | null;
|
|
||||||
slrGap: { text: string; short: boolean } | null;
|
|
||||||
status: 'ok' | 'short' | 'unknown';
|
status: 'ok' | 'short' | 'unknown';
|
||||||
} = {
|
} = { crrRate: null, slrRate: null, crrRequired: '—', slrRequired: '—', status: 'unknown' };
|
||||||
crrRate: null,
|
|
||||||
slrRate: null,
|
|
||||||
crrRequired: '—',
|
|
||||||
slrRequired: '—',
|
|
||||||
crrGap: null,
|
|
||||||
slrGap: null,
|
|
||||||
status: 'unknown'
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Array of all user activities */
|
/** Array of all user activities */
|
||||||
userActivity: string[];
|
userActivity: string[];
|
||||||
@@ -104,27 +93,8 @@ export class DashboardComponent implements OnInit {
|
|||||||
|
|
||||||
/** Fills the CRR/SLR tile from the return, leaving it dashed where the return cannot compute. */
|
/** Fills the CRR/SLR tile from the return, leaving it dashed where the return cannot compute. */
|
||||||
private loadCrrSlr(): void {
|
private loadCrrSlr(): void {
|
||||||
// Rupees, not crore. Rounding to crore on a bank this size collapsed the figures to "₹2 Cr"
|
const crore = (v: number | null | undefined): string =>
|
||||||
// and "₹13 Cr" — two requirements a crore apart both read the same, and a tile a treasury desk
|
v === null || v === undefined ? '—' : `₹${Math.round(v / 1e7).toLocaleString('en-IN')} Cr`;
|
||||||
// glances at said nothing it could act on.
|
|
||||||
// Scaled to the Indian crore/lakh so the tile holds its figure however large the bank grows.
|
|
||||||
// Plain rupees ran to nineteen characters on a seventy-crore requirement and overflowed the
|
|
||||||
// card; rounding to crore outright collapsed two requirements a crore apart to the same "₹2
|
|
||||||
// Cr". This keeps four or five characters and two decimals of precision at any scale.
|
|
||||||
const money = (v: number | null | undefined): string => {
|
|
||||||
if (v === null || v === undefined) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
const abs = Math.abs(v);
|
|
||||||
const sign = v < 0 ? '-' : '';
|
|
||||||
if (abs >= 1e7) {
|
|
||||||
return `${sign}₹${(abs / 1e7).toLocaleString('en-IN', { maximumFractionDigits: 2 })} Cr`;
|
|
||||||
}
|
|
||||||
if (abs >= 1e5) {
|
|
||||||
return `${sign}₹${(abs / 1e5).toLocaleString('en-IN', { maximumFractionDigits: 2 })} L`;
|
|
||||||
}
|
|
||||||
return `${sign}₹${Math.round(abs).toLocaleString('en-IN')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// The statutory Form 1, which is the return this bank files — twice a month, at the 15th and at
|
// The statutory Form 1, which is the return this bank files — twice a month, at the 15th and at
|
||||||
// the month end. The working report's 45-day lag lands mid-fortnight, on a date the bank never
|
// the month end. The working report's 45-day lag lands mid-fortnight, on a date the bank never
|
||||||
@@ -137,8 +107,8 @@ export class DashboardComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.crrSlr.crrRate = data?.crrPercentage ?? null;
|
this.crrSlr.crrRate = data?.crrPercentage ?? null;
|
||||||
this.crrSlr.slrRate = data?.slrPercentage ?? null;
|
this.crrSlr.slrRate = data?.slrPercentage ?? null;
|
||||||
this.crrSlr.crrRequired = money(data?.partB?.required?.[latest]);
|
this.crrSlr.crrRequired = crore(data?.partB?.required?.[latest]);
|
||||||
this.crrSlr.slrRequired = money(data?.partC?.required?.[latest]);
|
this.crrSlr.slrRequired = crore(data?.partC?.required?.[latest]);
|
||||||
|
|
||||||
// Compliant only where BOTH ratios are met: the tile carries one tag for the pair, and a
|
// Compliant only where BOTH ratios are met: the tile carries one tag for the pair, and a
|
||||||
// bank short on SLR is not within limits because its cash reserve happens to be ample.
|
// bank short on SLR is not within limits because its cash reserve happens to be ample.
|
||||||
@@ -146,19 +116,6 @@ export class DashboardComponent implements OnInit {
|
|||||||
const crrNeed = data?.partB?.required?.[latest];
|
const crrNeed = data?.partB?.required?.[latest];
|
||||||
const slrHeld = data?.partC?.maintained?.[latest];
|
const slrHeld = data?.partC?.maintained?.[latest];
|
||||||
const slrNeed = data?.partC?.required?.[latest];
|
const slrNeed = data?.partC?.required?.[latest];
|
||||||
// Each ratio's own standing, under its requirement: surplus where it is met, and the
|
|
||||||
// shortfall — in red, named as one — where it is not. A single "Shortfall" tag said the bank
|
|
||||||
// was short without saying which ratio or by how much, which is the first thing asked.
|
|
||||||
const gap = (held: number | null | undefined, need: number | null | undefined) => {
|
|
||||||
if (held === null || held === undefined || need === null || need === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const short = held < need;
|
|
||||||
return { text: `${short ? 'Short' : 'Surplus'} ${money(Math.abs(held - need))}`, short };
|
|
||||||
};
|
|
||||||
this.crrSlr.crrGap = gap(crrHeld, crrNeed);
|
|
||||||
this.crrSlr.slrGap = gap(slrHeld, slrNeed);
|
|
||||||
|
|
||||||
if ([
|
if ([
|
||||||
crrHeld,
|
crrHeld,
|
||||||
crrNeed,
|
crrNeed,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -108,6 +108,7 @@
|
|||||||
|
|
||||||
.km-main {
|
.km-main {
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
|
max-width: 1400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.km-page-head {
|
.km-page-head {
|
||||||
@@ -1777,7 +1778,3 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.km-rep {
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { NgModule } from '@angular/core';
|
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
|
||||||
import { Route } from '../core/route/route.service';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
|
||||||
Route.withShell([
|
|
||||||
{
|
|
||||||
path: 'ai/loan-eligibility',
|
|
||||||
loadComponent: () => import('./loan-eligibility.component').then((m) => m.LoanEligibilityComponent),
|
|
||||||
data: { title: 'Loan Eligibility', breadcrumb: 'Loan Eligibility' }
|
|
||||||
}
|
|
||||||
])
|
|
||||||
];
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [RouterModule.forChild(routes)],
|
|
||||||
exports: [RouterModule]
|
|
||||||
})
|
|
||||||
export class LoanEligibilityRoutingModule {}
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
<!--
|
|
||||||
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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="pg">
|
|
||||||
@if (!loading) {
|
|
||||||
<header class="pg-head">
|
|
||||||
<div>
|
|
||||||
<div class="pg-eyebrow">AI / INTELLIGENCE · LENDING & RISK</div>
|
|
||||||
<h1 class="pg-title">Loan Eligibility</h1>
|
|
||||||
<p class="pg-sub">
|
|
||||||
How our loans are running (RBI asset classification) and which loan products the good borrowers are eligible
|
|
||||||
for, using the eligibility rules set on each loan product.
|
|
||||||
@if (summary) {
|
|
||||||
As on {{ summary.asOnDate }}.
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (error) {
|
|
||||||
<p class="pg-sub le-error">{{ error }}</p>
|
|
||||||
}
|
|
||||||
@if (loading) {
|
|
||||||
<div class="le-loader">
|
|
||||||
<div class="le-spinner"></div>
|
|
||||||
<div class="le-loader-title">Loading loan book…</div>
|
|
||||||
<ul class="le-loader-steps">
|
|
||||||
@for (st of loadingSteps; track $index) {
|
|
||||||
<li [class.le-done]="$index < loadingStep" [class.le-now]="$index === loadingStep">
|
|
||||||
{{ $index < loadingStep ? '✓' : $index === loadingStep ? '…' : '' }} {{ st }}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
<div class="le-progress"><span [style.width.%]="((loadingStep + 1) / loadingSteps.length) * 100"></span></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (summary; as s) {
|
|
||||||
<div class="le-kpis">
|
|
||||||
<div class="le-kpi">
|
|
||||||
<span class="le-kpi-l">Active loans</span>
|
|
||||||
<span class="le-kpi-v">{{ s.activeLoans }}</span>
|
|
||||||
<span class="le-kpi-s">₹{{ s.activeOutstanding | number: '1.0-0' }} outstanding</span>
|
|
||||||
</div>
|
|
||||||
<div class="le-kpi le-kpi--good">
|
|
||||||
<span class="le-kpi-l">Running well (Standard + SMA)</span>
|
|
||||||
<span class="le-kpi-v">{{ s.performingLoans }}</span>
|
|
||||||
<span class="le-kpi-s">₹{{ s.performingOutstanding | number: '1.0-0' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="le-kpi le-kpi--bad">
|
|
||||||
<span class="le-kpi-l">Gone bad (NPA)</span>
|
|
||||||
<span class="le-kpi-v">{{ s.npaLoans }}</span>
|
|
||||||
<span class="le-kpi-s">₹{{ s.npaOutstanding | number: '1.0-0' }} · Gross NPA {{ s.grossNpaPct }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="le-kpi">
|
|
||||||
<span class="le-kpi-l">Written off / Closed</span>
|
|
||||||
<span class="le-kpi-v">{{ s.writtenOffLoans }} / {{ s.closedLoans }}</span>
|
|
||||||
<span class="le-kpi-s">₹{{ s.writtenOffAmount | number: '1.0-0' }} written off</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cctr-tabs le-tabs le-views">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pg-btn pg-btn--ghost"
|
|
||||||
[class.pg-btn--active]="view === 'BOOK'"
|
|
||||||
(click)="view = 'BOOK'"
|
|
||||||
>
|
|
||||||
Loan book (RBI classes & products)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pg-btn pg-btn--ghost"
|
|
||||||
[class.pg-btn--active]="view === 'BORROWERS'"
|
|
||||||
(click)="view = 'BORROWERS'"
|
|
||||||
>
|
|
||||||
Borrowers & eligibility (Good {{ s.goodBorrowers }} · Watch {{ s.watchBorrowers }} · Bad
|
|
||||||
{{ s.badBorrowers }})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (view === 'BOOK') {
|
|
||||||
<div class="pg-card">
|
|
||||||
<div class="pos-section-label">Loans by RBI asset classification (days past due)</div>
|
|
||||||
<table class="pg-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Class</th>
|
|
||||||
<th>Days past due</th>
|
|
||||||
<th class="num">Loans</th>
|
|
||||||
<th class="num">Outstanding</th>
|
|
||||||
<th class="num">Overdue</th>
|
|
||||||
<th>Share of book</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (st of performingStages; track st.stage) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="le-dot"
|
|
||||||
[class.le-dot--good]="st.stage === 'STANDARD'"
|
|
||||||
[class.le-dot--warn]="st.stage !== 'STANDARD'"
|
|
||||||
></span>
|
|
||||||
{{ st.label }}
|
|
||||||
</td>
|
|
||||||
<td>{{ st.dpdRange }}</td>
|
|
||||||
<td class="num">{{ st.loans }}</td>
|
|
||||||
<td class="num">₹{{ st.outstanding | number: '1.2-2' }}</td>
|
|
||||||
<td class="num">₹{{ st.overdue | number: '1.2-2' }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="le-bar"><span [style.width.%]="share(st)"></span></span> {{ share(st) }}%
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
@for (st of npaStages; track st.stage) {
|
|
||||||
<tr>
|
|
||||||
<td><span class="le-dot le-dot--bad"></span> {{ st.label }} (NPA)</td>
|
|
||||||
<td>{{ st.dpdRange }}</td>
|
|
||||||
<td class="num">{{ st.loans }}</td>
|
|
||||||
<td class="num">₹{{ st.outstanding | number: '1.2-2' }}</td>
|
|
||||||
<td class="num">₹{{ st.overdue | number: '1.2-2' }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="le-bar le-bar--bad"><span [style.width.%]="share(st)"></span></span> {{ share(st) }}%
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
@for (st of closedStages; track st.stage) {
|
|
||||||
<tr class="le-muted">
|
|
||||||
<td>{{ st.label }}</td>
|
|
||||||
<td>{{ st.dpdRange }}</td>
|
|
||||||
<td class="num">{{ st.loans }}</td>
|
|
||||||
<td class="num">–</td>
|
|
||||||
<td class="num">–</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pg-card">
|
|
||||||
<div class="pos-section-label">Active loans by product</div>
|
|
||||||
<table class="pg-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Product</th>
|
|
||||||
<th class="num">Active</th>
|
|
||||||
<th class="num">Running well</th>
|
|
||||||
<th class="num">NPA</th>
|
|
||||||
<th class="num">Outstanding</th>
|
|
||||||
<th class="num">NPA outstanding</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (p of s.products; track p.productId) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ p.productName }}</td>
|
|
||||||
<td class="num">{{ p.activeLoans }}</td>
|
|
||||||
<td class="num">{{ p.performingLoans }}</td>
|
|
||||||
<td class="num">{{ p.npaLoans }}</td>
|
|
||||||
<td class="num">₹{{ p.outstanding | number: '1.2-2' }}</td>
|
|
||||||
<td class="num">₹{{ p.npaOutstanding | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="pg-empty">No active loans.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pg-card">
|
|
||||||
<div class="pos-section-label">Share linking to borrowing (5% unsecured · 2.5% secured)</div>
|
|
||||||
<div class="le-facts">
|
|
||||||
<span>Shares required on running loans: ₹{{ s.shareRequired | number: '1.0-0' }}</span>
|
|
||||||
<span>Shares held by these members: ₹{{ s.shareHeld | number: '1.0-0' }}</span>
|
|
||||||
<span [class.le-late]="s.shareShortMembers > 0"
|
|
||||||
>Members short: {{ s.shareShortMembers }} (₹{{ s.shareShortfall | number: '1.0-0' }} to collect)</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (view === 'BORROWERS') {
|
|
||||||
<div class="pg-card">
|
|
||||||
<div class="pos-section-label">
|
|
||||||
Borrowers — Good {{ s.goodBorrowers }} · Watch {{ s.watchBorrowers }} · Bad {{ s.badBorrowers }}
|
|
||||||
</div>
|
|
||||||
<div class="cctr-tabs le-tabs">
|
|
||||||
@for (c of categories; track c.value) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pg-btn pg-btn--ghost"
|
|
||||||
[class.pg-btn--active]="category === c.value"
|
|
||||||
(click)="setCategory(c.value)"
|
|
||||||
>
|
|
||||||
{{ c.label }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="le-search"
|
|
||||||
placeholder="Search name / account no"
|
|
||||||
[(ngModel)]="search"
|
|
||||||
(keyup.enter)="loadBorrowers()"
|
|
||||||
/>
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="loadBorrowers()">Search</button>
|
|
||||||
</div>
|
|
||||||
<p class="pg-sub le-help">
|
|
||||||
Good: every running loan is regular (0 days past due) and nothing was ever written off. Watch: a loan is
|
|
||||||
overdue up to 90 days (SMA). Bad: a loan is NPA or written off. Eligibility is worked out for good borrowers
|
|
||||||
only; a fresh loan also needs a clean EMI record (at most 2 EMIs ever paid late).
|
|
||||||
</p>
|
|
||||||
<table class="pg-table le-compact">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Borrower</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th class="num">Running / Closed</th>
|
|
||||||
<th>EMI record</th>
|
|
||||||
<th class="num">Outstanding</th>
|
|
||||||
<th class="num">Max days overdue</th>
|
|
||||||
<th>Worst class</th>
|
|
||||||
<th>More loan?</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@if (borrowersLoading) {
|
|
||||||
<tr>
|
|
||||||
<td colspan="9" class="pg-empty">
|
|
||||||
<span class="le-spinner le-spinner--sm"></span> Fetching borrowers…
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
@for (b of borrowersLoading ? [] : pagedBorrowers; track b.clientId) {
|
|
||||||
<tr [class.le-selected]="selected?.clientId === b.clientId">
|
|
||||||
<td>
|
|
||||||
{{ b.clientName }} <small>({{ b.clientAccountNo }})</small>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="le-chip" [ngClass]="'le-chip--' + b.category.toLowerCase()">{{
|
|
||||||
categoryLabel(b.category)
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ b.activeLoans }} / {{ b.closedLoans }}</td>
|
|
||||||
<td>
|
|
||||||
{{ b.emisPaid - b.emisLate }} / {{ b.emisPaid }} on time
|
|
||||||
@if (b.emisLate) {
|
|
||||||
<small class="le-late">({{ b.emisLate }} late)</small>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="num">₹{{ b.outstanding | number: '1.2-2' }}</td>
|
|
||||||
<td class="num">{{ b.maxDpd }}</td>
|
|
||||||
<td>{{ b.worstStage || '–' }}</td>
|
|
||||||
<td>
|
|
||||||
@if (b.eligibleProducts > 0) {
|
|
||||||
<span class="le-chip le-chip--good">Yes · {{ b.eligibleProducts }} products</span>
|
|
||||||
} @else {
|
|
||||||
<span class="le-chip le-chip--bad">No</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="openingId !== null" (click)="open(b)">
|
|
||||||
{{ openingId === b.clientId ? 'Loading…' : 'View' }}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="9" class="pg-empty">No borrowers in this category.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
@if (borrowers.length > pageSize) {
|
|
||||||
<div class="le-pager">
|
|
||||||
<span>Showing {{ page * pageSize + 1 }}–{{ pageEnd }} of {{ borrowers.length }}</span>
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="page === 0" (click)="page = page - 1">
|
|
||||||
‹ Prev
|
|
||||||
</button>
|
|
||||||
@for (p of pages; track p) {
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" [class.pg-btn--active]="p === page" (click)="page = p">
|
|
||||||
{{ p + 1 }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pg-btn pg-btn--ghost"
|
|
||||||
[disabled]="page >= pages.length - 1"
|
|
||||||
(click)="page = page + 1"
|
|
||||||
>
|
|
||||||
Next ›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (selected; as b) {
|
|
||||||
<div class="le-overlay" (click)="close()">
|
|
||||||
<div class="le-modal" role="dialog" aria-modal="true" (click)="$event.stopPropagation()">
|
|
||||||
<div class="le-modal-head">
|
|
||||||
<div class="pos-section-label">
|
|
||||||
{{ b.clientName }} ({{ b.clientAccountNo }}) — {{ categoryLabel(b.category) }} borrower
|
|
||||||
</div>
|
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="close()">Close</button>
|
|
||||||
</div>
|
|
||||||
<div class="le-facts">
|
|
||||||
<span>Age: {{ b.age ?? 'not recorded' }}</span>
|
|
||||||
<span
|
|
||||||
>Monthly income: {{ b.monthlyIncome ? '₹' + (b.monthlyIncome | number: '1.0-0') : 'not recorded' }}</span
|
|
||||||
>
|
|
||||||
<span>Current EMIs: ₹{{ b.currentEmi | number: '1.0-0' }}</span>
|
|
||||||
<span>EMI record: {{ b.emisPaid - b.emisLate }} of {{ b.emisPaid }} paid on time</span>
|
|
||||||
<span>Deposits (savings / FD / RD): ₹{{ b.depositBalance | number: '1.0-0' }}</span>
|
|
||||||
<span [class.le-late]="b.shareHeld < b.shareRequired"
|
|
||||||
>Shares held: ₹{{ b.shareHeld | number: '1.0-0' }} (needed ₹{{ b.shareRequired | number: '1.0-0' }} incl.
|
|
||||||
membership)</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@for (a of b.accounts ?? []; track a.accountNo) {
|
|
||||||
<div class="pos-section-label">
|
|
||||||
{{ a.type }} account {{ a.accountNo }} — last 6 months (money in, expenses, balance)
|
|
||||||
</div>
|
|
||||||
<table class="pg-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Month</th>
|
|
||||||
<th class="num">Credits (in)</th>
|
|
||||||
<th class="num">Debits (expenses + EMIs)</th>
|
|
||||||
<th class="num">Left over</th>
|
|
||||||
<th class="num">Average balance</th>
|
|
||||||
<th class="num">Cheque / ECS returns</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (m of a.months; track m.month) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ m.month }}</td>
|
|
||||||
<td class="num">₹{{ m.credits | number: '1.0-0' }}</td>
|
|
||||||
<td class="num">₹{{ m.debits | number: '1.0-0' }}</td>
|
|
||||||
<td class="num" [class.le-late]="m.credits < m.debits">
|
|
||||||
₹{{ m.credits - m.debits | number: '1.0-0' }}
|
|
||||||
</td>
|
|
||||||
<td class="num">₹{{ m.avgBalance | number: '1.0-0' }}</td>
|
|
||||||
<td class="num" [class.le-late]="m.returns > 0">{{ m.returns }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
<tr class="le-total">
|
|
||||||
<td>Monthly average</td>
|
|
||||||
<td class="num">₹{{ b.avgCredits | number: '1.0-0' }}</td>
|
|
||||||
<td class="num">₹{{ b.avgDebits | number: '1.0-0' }}</td>
|
|
||||||
<td class="num" [class.le-late]="b.avgCredits < b.avgDebits">
|
|
||||||
₹{{ b.avgCredits - b.avgDebits | number: '1.0-0' }}
|
|
||||||
</td>
|
|
||||||
<td class="num">
|
|
||||||
@if (b.avgCredits > 0) {
|
|
||||||
Spends {{ (b.avgDebits / b.avgCredits) * 100 | number: '1.0-0' }}% of income
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ b.returns }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="pos-section-label">Loans</div>
|
|
||||||
<table class="pg-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Loan</th>
|
|
||||||
<th>Product</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Class</th>
|
|
||||||
<th class="num">Days overdue</th>
|
|
||||||
<th class="num">EMIs on time</th>
|
|
||||||
<th class="num">Disbursed</th>
|
|
||||||
<th class="num">Outstanding</th>
|
|
||||||
<th class="num">Overdue</th>
|
|
||||||
<th class="num">Shares needed</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (l of b.loans ?? []; track l.loanId) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ l.accountNo }}</td>
|
|
||||||
<td>{{ l.productName }}</td>
|
|
||||||
<td>{{ l.status }}</td>
|
|
||||||
<td>{{ l.stage }}</td>
|
|
||||||
<td class="num">{{ l.dpd }}</td>
|
|
||||||
<td class="num">{{ l.emisPaid - l.emisLate }} / {{ l.emisPaid }}</td>
|
|
||||||
<td class="num">₹{{ l.disbursed | number: '1.2-2' }}</td>
|
|
||||||
<td class="num">₹{{ l.outstanding | number: '1.2-2' }}</td>
|
|
||||||
<td class="num">₹{{ l.overdue | number: '1.2-2' }}</td>
|
|
||||||
<td class="num">
|
|
||||||
@if (l.shareRequired) {
|
|
||||||
₹{{ l.shareRequired | number: '1.0-0' }} <small>({{ l.sharePct }}%)</small>
|
|
||||||
} @else {
|
|
||||||
–
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
@if (b.category === 'GOOD') {
|
|
||||||
<div class="pos-section-label">Eligible loan products</div>
|
|
||||||
<table class="pg-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Product</th>
|
|
||||||
<th>Eligible</th>
|
|
||||||
<th class="num">Up to</th>
|
|
||||||
<th class="num">Shares to take</th>
|
|
||||||
<th class="num">In hand</th>
|
|
||||||
<th>Why</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (e of b.eligibility ?? []; track e.productId) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ e.productName }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="le-chip" [ngClass]="e.eligible ? 'le-chip--good' : 'le-chip--bad'">{{
|
|
||||||
e.eligible ? 'Yes' : 'No'
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ e.eligibleAmount ? '₹' + (e.eligibleAmount | number: '1.0-0') : '–' }}</td>
|
|
||||||
<td class="num">
|
|
||||||
@if (e.shareToBuy) {
|
|
||||||
₹{{ e.shareToBuy | number: '1.0-0' }} <small>({{ e.sharePct }}%)</small>
|
|
||||||
} @else {
|
|
||||||
–
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ e.netInHand ? '₹' + (e.netInHand | number: '1.0-0') : '–' }}</td>
|
|
||||||
<td class="le-why">
|
|
||||||
@for (f of e.failed; track $index) {
|
|
||||||
<div class="le-fail">✗ {{ f }}</div>
|
|
||||||
}
|
|
||||||
@for (p of e.passed; track $index) {
|
|
||||||
<div class="le-pass">✓ {{ p }}</div>
|
|
||||||
}
|
|
||||||
@for (n of e.notes; track $index) {
|
|
||||||
<div class="le-note">• {{ n }}</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="pg-empty">No active loan products.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
} @else {
|
|
||||||
<p class="pg-sub le-help">
|
|
||||||
Eligibility is not worked out for this borrower because a loan is overdue, NPA or written off.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<ul class="le-notes">
|
|
||||||
@for (n of s.notes; track $index) {
|
|
||||||
<li>{{ n }}</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
.le-kpis {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-kpi {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid #e4e7ec;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-kpi--good {
|
|
||||||
border-left: 4px solid #12b76a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-kpi--bad {
|
|
||||||
border-left: 4px solid #d92d20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-kpi-l {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #667085;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-kpi-v {
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-kpi-s {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #475467;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-dot {
|
|
||||||
display: inline-block;
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
margin-right: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-dot--good {
|
|
||||||
background: #12b76a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-dot--warn {
|
|
||||||
background: #f79009;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-dot--bad {
|
|
||||||
background: #d92d20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-bar {
|
|
||||||
display: inline-block;
|
|
||||||
width: 120px;
|
|
||||||
height: 8px;
|
|
||||||
margin-right: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #f2f4f7;
|
|
||||||
vertical-align: middle;
|
|
||||||
|
|
||||||
span {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
background: #12b76a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-bar--bad span {
|
|
||||||
background: #d92d20;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-muted {
|
|
||||||
color: #667085;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-tabs {
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-search {
|
|
||||||
min-width: 220px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid #d0d5dd;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-help {
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-chip {
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-chip--good {
|
|
||||||
background: #ecfdf3;
|
|
||||||
color: #067647;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-chip--watch {
|
|
||||||
background: #fffaeb;
|
|
||||||
color: #b54708;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-chip--bad {
|
|
||||||
background: #fef3f2;
|
|
||||||
color: #b42318;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-selected {
|
|
||||||
background: #f5f8ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-facts {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 24px;
|
|
||||||
padding: 8px 16px 12px;
|
|
||||||
color: #344054;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-why div {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-fail {
|
|
||||||
color: #b42318;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-pass {
|
|
||||||
color: #067647;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-note {
|
|
||||||
color: #667085;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-error {
|
|
||||||
color: #b42318;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-notes {
|
|
||||||
margin: 8px 0 24px;
|
|
||||||
color: #667085;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-late {
|
|
||||||
color: #b42318;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 48px 16px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: rgb(15 23 42 / 55%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-modal {
|
|
||||||
width: min(1100px, 100%);
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 20px 50px rgb(0 0 0 / 30%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-modal-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-views {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-compact td {
|
|
||||||
padding-top: 6px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-compact .pg-btn {
|
|
||||||
padding: 4px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-pager {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 12px;
|
|
||||||
|
|
||||||
span {
|
|
||||||
margin-right: auto;
|
|
||||||
color: #667085;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-loader {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
margin: 32px auto;
|
|
||||||
padding: 36px 24px;
|
|
||||||
max-width: 520px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 4px 16px rgb(16 24 40 / 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-loader-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #1d2939;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-loader-steps {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.9;
|
|
||||||
color: #98a2b3;
|
|
||||||
|
|
||||||
.le-done {
|
|
||||||
color: #067647;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-now {
|
|
||||||
color: #1d4ed8;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-progress {
|
|
||||||
width: 100%;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #eaecf0;
|
|
||||||
|
|
||||||
span {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
background: #1d4ed8;
|
|
||||||
transition: width 0.6s ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-spinner {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border: 4px solid #dbe4ff;
|
|
||||||
border-top-color: #1d4ed8;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: le-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-spinner--sm {
|
|
||||||
display: inline-block;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-width: 2px;
|
|
||||||
margin-right: 6px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes le-spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.le-total td {
|
|
||||||
font-weight: 600;
|
|
||||||
border-top: 2px solid #d0d5dd;
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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, HostListener, OnDestroy, OnInit, inject } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
|
||||||
import {
|
|
||||||
Borrower,
|
|
||||||
BorrowerCategory,
|
|
||||||
LoanEligibilityService,
|
|
||||||
LoanEligibilitySummary,
|
|
||||||
LoanStageRow
|
|
||||||
} from './loan-eligibility.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loan Eligibility – how the loan book is running (RBI asset classes) and which loan products the good borrowers
|
|
||||||
* qualify for, using their EMI record and the eligibility rules set on each loan product.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-loan-eligibility',
|
|
||||||
templateUrl: './loan-eligibility.component.html',
|
|
||||||
styleUrls: [
|
|
||||||
'../cashier/cashier-shared.scss',
|
|
||||||
'../investment/investment-shared.scss',
|
|
||||||
'./loan-eligibility.component.scss'
|
|
||||||
],
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class LoanEligibilityComponent implements OnInit, OnDestroy {
|
|
||||||
private readonly service = inject(LoanEligibilityService);
|
|
||||||
|
|
||||||
summary: LoanEligibilitySummary | null = null;
|
|
||||||
borrowers: Borrower[] = [];
|
|
||||||
category: BorrowerCategory | '' = 'GOOD';
|
|
||||||
search = '';
|
|
||||||
selected: Borrower | null = null;
|
|
||||||
/** Which part of the page is shown, so the user need not scroll past the loan book to reach borrowers. */
|
|
||||||
view: 'BOOK' | 'BORROWERS' = 'BOOK';
|
|
||||||
page = 0;
|
|
||||||
readonly pageSize = 10;
|
|
||||||
loading = false;
|
|
||||||
borrowersLoading = false;
|
|
||||||
/** Borrower whose detail is being fetched (View button shows it). */
|
|
||||||
openingId: number | null = null;
|
|
||||||
error = '';
|
|
||||||
/** Steps shown while the book is being read. */
|
|
||||||
readonly loadingSteps = [
|
|
||||||
'Fetching loan accounts…',
|
|
||||||
'Reading repayment schedules and overdues…',
|
|
||||||
'Classifying loans as per RBI IRAC norms…',
|
|
||||||
'Running product eligibility rules…'
|
|
||||||
];
|
|
||||||
loadingStep = 0;
|
|
||||||
private stepTimer?: ReturnType<typeof setInterval>;
|
|
||||||
|
|
||||||
readonly categories: Array<{ value: BorrowerCategory | ''; label: string }> = [
|
|
||||||
{ value: 'GOOD', label: 'Good borrowers' },
|
|
||||||
{ value: 'WATCH', label: 'Watch (SMA)' },
|
|
||||||
{ value: 'BAD', label: 'Bad (NPA / written off)' },
|
|
||||||
{ value: '', label: 'All borrowers' }
|
|
||||||
];
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.loadingStep = 0;
|
|
||||||
this.stepTimer = setInterval(
|
|
||||||
() => (this.loadingStep = Math.min(this.loadingStep + 1, this.loadingSteps.length - 1)),
|
|
||||||
700
|
|
||||||
);
|
|
||||||
this.service.getSummary().subscribe({
|
|
||||||
next: (s) => {
|
|
||||||
this.summary = s;
|
|
||||||
this.stopLoading();
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
this.stopLoading();
|
|
||||||
this.error = this.message(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.loadBorrowers();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadBorrowers(): void {
|
|
||||||
this.selected = null;
|
|
||||||
this.borrowersLoading = true;
|
|
||||||
this.service.getBorrowers(this.category, this.search).subscribe({
|
|
||||||
next: (b) => {
|
|
||||||
this.borrowers = b;
|
|
||||||
this.page = 0;
|
|
||||||
this.borrowersLoading = false;
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
this.borrowersLoading = false;
|
|
||||||
this.error = this.message(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setCategory(c: BorrowerCategory | ''): void {
|
|
||||||
this.category = c;
|
|
||||||
this.loadBorrowers();
|
|
||||||
}
|
|
||||||
|
|
||||||
open(b: Borrower): void {
|
|
||||||
this.openingId = b.clientId;
|
|
||||||
this.service.getBorrower(b.clientId).subscribe({
|
|
||||||
next: (d) => {
|
|
||||||
this.openingId = null;
|
|
||||||
this.selected = d;
|
|
||||||
},
|
|
||||||
error: (err) => {
|
|
||||||
this.openingId = null;
|
|
||||||
this.error = this.message(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.stopLoading();
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopLoading(): void {
|
|
||||||
this.loading = false;
|
|
||||||
clearInterval(this.stepTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
get pagedBorrowers(): Borrower[] {
|
|
||||||
return this.borrowers.slice(this.page * this.pageSize, (this.page + 1) * this.pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
get pageEnd(): number {
|
|
||||||
return Math.min((this.page + 1) * this.pageSize, this.borrowers.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
get pages(): number[] {
|
|
||||||
return Array.from({ length: Math.ceil(this.borrowers.length / this.pageSize) }, (_, i) => i);
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void {
|
|
||||||
this.selected = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
|
||||||
onEscape(): void {
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
get performingStages(): LoanStageRow[] {
|
|
||||||
return (this.summary?.stages ?? []).filter((s) => s.group === 'PERFORMING');
|
|
||||||
}
|
|
||||||
|
|
||||||
get npaStages(): LoanStageRow[] {
|
|
||||||
return (this.summary?.stages ?? []).filter((s) => s.group === 'NPA');
|
|
||||||
}
|
|
||||||
|
|
||||||
get closedStages(): LoanStageRow[] {
|
|
||||||
return (this.summary?.stages ?? []).filter((s) => s.group === 'CLOSED');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Share of the active book in a stage, for the bar. */
|
|
||||||
share(s: LoanStageRow): number {
|
|
||||||
const total = this.summary?.activeOutstanding ?? 0;
|
|
||||||
return total > 0 ? Math.round((s.outstanding / total) * 1000) / 10 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryLabel(c: BorrowerCategory): string {
|
|
||||||
return c === 'GOOD' ? 'Good' : c === 'WATCH' ? 'Watch' : 'Bad';
|
|
||||||
}
|
|
||||||
|
|
||||||
private message(err: unknown): string {
|
|
||||||
if (err instanceof HttpErrorResponse) {
|
|
||||||
const e = err.error as { errors?: Array<{ defaultUserMessage?: string }>; defaultUserMessage?: string } | null;
|
|
||||||
return e?.errors?.[0]?.defaultUserMessage || e?.defaultUserMessage || err.message;
|
|
||||||
}
|
|
||||||
return String(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,726 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Demo data for Loan Eligibility. The bank's rules are applied to it: RBI asset classification on days past due, and
|
|
||||||
* product eligibility rules (age, age at maturity, income, FOIR, loans per customer, LTV on pledged deposits). The book is generated from a fixed seed, so the
|
|
||||||
* figures are the same on every load.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
Borrower,
|
|
||||||
BorrowerCategory,
|
|
||||||
BorrowerLoan,
|
|
||||||
LoanEligibilitySummary,
|
|
||||||
LoanStageRow,
|
|
||||||
ProductEligibility
|
|
||||||
} from './loan-eligibility.service';
|
|
||||||
|
|
||||||
interface DemoProduct {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
minAmount: number;
|
|
||||||
maxAmount: number;
|
|
||||||
annualRate: number;
|
|
||||||
tenureMonths: number;
|
|
||||||
minAge?: number;
|
|
||||||
maxAgeAtMaturity?: number;
|
|
||||||
minMonthlyIncome?: number;
|
|
||||||
maxFoir?: number;
|
|
||||||
maxLoansPerCustomer?: number;
|
|
||||||
/** Loan against deposits: % of the FD / RD balance that can be lent. */
|
|
||||||
ltvPct?: number;
|
|
||||||
/** Secured loans need shares of 2.5% of the loan, unsecured 5% (share linking to borrowing). */
|
|
||||||
secured: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DemoMonth {
|
|
||||||
month: string;
|
|
||||||
credits: number;
|
|
||||||
debits: number;
|
|
||||||
avgBalance: number;
|
|
||||||
returns: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DemoAccount {
|
|
||||||
type: 'Savings' | 'Current';
|
|
||||||
accountNo: string;
|
|
||||||
months: DemoMonth[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DemoLoan {
|
|
||||||
id: number;
|
|
||||||
accountNo: string;
|
|
||||||
productId: number;
|
|
||||||
status: 'Active' | 'Closed' | 'Written off';
|
|
||||||
dpd: number;
|
|
||||||
disbursed: number;
|
|
||||||
outstanding: number;
|
|
||||||
overdue: number;
|
|
||||||
emi: number;
|
|
||||||
/** EMIs fallen due so far, and how many of them were paid after the due date. */
|
|
||||||
emisPaid: number;
|
|
||||||
emisLate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DemoClient {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
accountNo: string;
|
|
||||||
age: number;
|
|
||||||
monthlyIncome?: number;
|
|
||||||
fdBalance: number;
|
|
||||||
savingsBalance: number;
|
|
||||||
loans: DemoLoan[];
|
|
||||||
/** Paid-up share capital held as a member. */
|
|
||||||
shareHeld: number;
|
|
||||||
accounts: DemoAccount[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEMO_PRODUCTS: DemoProduct[] = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Personal Loan',
|
|
||||||
minAmount: 25000,
|
|
||||||
maxAmount: 1000000,
|
|
||||||
annualRate: 11.5,
|
|
||||||
tenureMonths: 60,
|
|
||||||
minAge: 21,
|
|
||||||
maxAgeAtMaturity: 60,
|
|
||||||
minMonthlyIncome: 25000,
|
|
||||||
maxFoir: 50,
|
|
||||||
maxLoansPerCustomer: 1,
|
|
||||||
secured: false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Home Loan',
|
|
||||||
minAmount: 500000,
|
|
||||||
maxAmount: 5000000,
|
|
||||||
annualRate: 9,
|
|
||||||
tenureMonths: 240,
|
|
||||||
minAge: 23,
|
|
||||||
maxAgeAtMaturity: 70,
|
|
||||||
minMonthlyIncome: 40000,
|
|
||||||
maxFoir: 55,
|
|
||||||
maxLoansPerCustomer: 1,
|
|
||||||
secured: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Vehicle Loan',
|
|
||||||
minAmount: 100000,
|
|
||||||
maxAmount: 1500000,
|
|
||||||
annualRate: 9.75,
|
|
||||||
tenureMonths: 84,
|
|
||||||
minAge: 21,
|
|
||||||
maxAgeAtMaturity: 65,
|
|
||||||
minMonthlyIncome: 20000,
|
|
||||||
maxFoir: 50,
|
|
||||||
maxLoansPerCustomer: 2,
|
|
||||||
secured: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: 'Gold Loan',
|
|
||||||
minAmount: 10000,
|
|
||||||
maxAmount: 1000000,
|
|
||||||
annualRate: 9,
|
|
||||||
tenureMonths: 12,
|
|
||||||
minAge: 18,
|
|
||||||
maxLoansPerCustomer: 3,
|
|
||||||
secured: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: 'Loan against FD',
|
|
||||||
minAmount: 5000,
|
|
||||||
maxAmount: 2500000,
|
|
||||||
annualRate: 8.5,
|
|
||||||
tenureMonths: 12,
|
|
||||||
minAge: 18,
|
|
||||||
ltvPct: 90,
|
|
||||||
secured: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
name: 'MSME / Business Loan',
|
|
||||||
minAmount: 200000,
|
|
||||||
maxAmount: 2500000,
|
|
||||||
annualRate: 12,
|
|
||||||
tenureMonths: 84,
|
|
||||||
minAge: 25,
|
|
||||||
maxAgeAtMaturity: 65,
|
|
||||||
minMonthlyIncome: 50000,
|
|
||||||
maxFoir: 60,
|
|
||||||
maxLoansPerCustomer: 1,
|
|
||||||
secured: true
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Membership share every member holds, over and above the shares linked to loans. */
|
|
||||||
const MEMBERSHIP_SHARE = 1000;
|
|
||||||
const MONTHS = [
|
|
||||||
'Apr 2026',
|
|
||||||
'May 2026',
|
|
||||||
'Jun 2026',
|
|
||||||
'Jul 2026',
|
|
||||||
'Aug 2026',
|
|
||||||
'Sep 2026'
|
|
||||||
];
|
|
||||||
/** Late EMIs a borrower may have in their history and still get a fresh loan. */
|
|
||||||
const MAX_LATE_EMIS = 2;
|
|
||||||
/** Cheque / ECS returns allowed in the last six months for a fresh loan. */
|
|
||||||
const MAX_RETURNS = 1;
|
|
||||||
|
|
||||||
function sharePct(productId: number): number {
|
|
||||||
return DEMO_PRODUCTS.find((p) => p.id === productId)?.secured === false ? 5 : 2.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ceil100(n: number): number {
|
|
||||||
return Math.ceil(n / 100) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FIRST = [
|
|
||||||
'Ramesh',
|
|
||||||
'Sunita',
|
|
||||||
'Amit',
|
|
||||||
'Priya',
|
|
||||||
'Rajesh',
|
|
||||||
'Kavita',
|
|
||||||
'Suresh',
|
|
||||||
'Anjali',
|
|
||||||
'Mahesh',
|
|
||||||
'Pooja',
|
|
||||||
'Dinesh',
|
|
||||||
'Neha',
|
|
||||||
'Vijay',
|
|
||||||
'Rekha',
|
|
||||||
'Sanjay',
|
|
||||||
'Meena',
|
|
||||||
'Arvind',
|
|
||||||
'Geeta',
|
|
||||||
'Manoj',
|
|
||||||
'Seema',
|
|
||||||
'Rahul',
|
|
||||||
'Asha',
|
|
||||||
'Deepak',
|
|
||||||
'Nisha',
|
|
||||||
'Ashok',
|
|
||||||
'Lata',
|
|
||||||
'Prakash',
|
|
||||||
'Usha',
|
|
||||||
'Anil',
|
|
||||||
'Shobha'
|
|
||||||
];
|
|
||||||
const LAST = [
|
|
||||||
'Sharma',
|
|
||||||
'Verma',
|
|
||||||
'Patel',
|
|
||||||
'Gupta',
|
|
||||||
'Singh',
|
|
||||||
'Yadav',
|
|
||||||
'Jain',
|
|
||||||
'Agrawal',
|
|
||||||
'Choudhary',
|
|
||||||
'Mishra',
|
|
||||||
'Tiwari',
|
|
||||||
'Rathore',
|
|
||||||
'Soni',
|
|
||||||
'Malviya',
|
|
||||||
'Dubey',
|
|
||||||
'Saxena'
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Small deterministic generator so the demo looks the same every time. */
|
|
||||||
function rng(seed: number): () => number {
|
|
||||||
let s = seed;
|
|
||||||
return () => {
|
|
||||||
s = (s * 1664525 + 1013904223) % 4294967296;
|
|
||||||
return s / 4294967296;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function emiFor(principal: number, annualRate: number, months: number): number {
|
|
||||||
const r = annualRate / 1200;
|
|
||||||
return r === 0 ? principal / months : (principal * r) / (1 - Math.pow(1 + r, -months));
|
|
||||||
}
|
|
||||||
|
|
||||||
function principalForEmi(emi: number, annualRate: number, months: number): number {
|
|
||||||
const r = annualRate / 1200;
|
|
||||||
return r === 0 ? emi * months : (emi * (1 - Math.pow(1 + r, -months))) / r;
|
|
||||||
}
|
|
||||||
|
|
||||||
function round(n: number, step = 1): number {
|
|
||||||
return Math.round(n / step) * step;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBook(): DemoClient[] {
|
|
||||||
const rand = rng(20261019);
|
|
||||||
const pick = <T>(a: T[]): T => a[Math.floor(rand() * a.length)];
|
|
||||||
const clients: DemoClient[] = [];
|
|
||||||
// Separate streams for the repayment record and the bank accounts, so the book above stays as it was.
|
|
||||||
const track = rng(7);
|
|
||||||
const bank = rng(11);
|
|
||||||
let loanId = 1;
|
|
||||||
for (let i = 0; i < 64; i++) {
|
|
||||||
const salaried = rand() < 0.85;
|
|
||||||
const client: DemoClient = {
|
|
||||||
id: 1001 + i,
|
|
||||||
name: `${FIRST[i % FIRST.length]} ${pick(LAST)}`,
|
|
||||||
accountNo: String(100010 + i).padStart(9, '0'),
|
|
||||||
age: 22 + Math.floor(rand() * 40),
|
|
||||||
monthlyIncome: salaried ? round(18000 + rand() * 132000, 500) : undefined,
|
|
||||||
fdBalance: rand() < 0.45 ? round(25000 + rand() * 775000, 1000) : 0,
|
|
||||||
savingsBalance: round(2000 + rand() * 148000, 100),
|
|
||||||
loans: [],
|
|
||||||
shareHeld: 0,
|
|
||||||
accounts: []
|
|
||||||
};
|
|
||||||
const count = 1 + Math.floor(rand() * 3);
|
|
||||||
// Behaviour of the borrower decides how all their loans run: most pay on time, some slip, a few go bad.
|
|
||||||
const behaviour = rand();
|
|
||||||
for (let k = 0; k < count; k++) {
|
|
||||||
// Stay within each product's age and loans-per-customer rules, as the bank would have at sanction.
|
|
||||||
const product = pick(
|
|
||||||
DEMO_PRODUCTS.filter(
|
|
||||||
(p) =>
|
|
||||||
(p.id !== 5 || client.fdBalance > 0) &&
|
|
||||||
client.age >= (p.minAge ?? 18) &&
|
|
||||||
client.loans.filter((l) => l.productId === p.id).length < (p.maxLoansPerCustomer ?? 3)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const disbursed = round(
|
|
||||||
product.minAmount + rand() * Math.min(product.maxAmount - product.minAmount, 1800000),
|
|
||||||
1000
|
|
||||||
);
|
|
||||||
const closed = rand() < 0.25;
|
|
||||||
let dpd = 0;
|
|
||||||
if (!closed) {
|
|
||||||
if (behaviour > 0.975) {
|
|
||||||
dpd = 91 + Math.floor(rand() * 900);
|
|
||||||
} else if (behaviour > 0.72) {
|
|
||||||
dpd = 1 + Math.floor(rand() * 89);
|
|
||||||
} else if (behaviour > 0.705) {
|
|
||||||
dpd = 91 + Math.floor(rand() * 300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const writtenOff = !closed && dpd > 700 && rand() < 0.5;
|
|
||||||
const outstanding = closed || writtenOff ? 0 : round(disbursed * (0.25 + rand() * 0.7), 1);
|
|
||||||
const emi = round(emiFor(disbursed, product.annualRate, product.tenureMonths), 1);
|
|
||||||
const emisPaid = closed
|
|
||||||
? Math.min(product.tenureMonths, 12 + Math.floor(track() * 24))
|
|
||||||
: writtenOff
|
|
||||||
? Math.max(3, Math.round(product.tenureMonths * 0.3))
|
|
||||||
: Math.max(1, Math.round(product.tenureMonths * (1 - outstanding / disbursed)));
|
|
||||||
// Most good payers never miss a date; some were late once or a few times but are regular today.
|
|
||||||
let emisLate = 0;
|
|
||||||
if (behaviour > 0.705) {
|
|
||||||
emisLate = 2 + Math.floor(track() * 6);
|
|
||||||
} else if (behaviour > 0.55) {
|
|
||||||
emisLate = 1 + Math.floor(track() * 4);
|
|
||||||
}
|
|
||||||
emisLate = Math.min(emisLate, emisPaid);
|
|
||||||
client.loans.push({
|
|
||||||
id: loanId,
|
|
||||||
accountNo: String(500000 + loanId).padStart(9, '0'),
|
|
||||||
productId: product.id,
|
|
||||||
status: closed ? 'Closed' : writtenOff ? 'Written off' : 'Active',
|
|
||||||
dpd: closed || writtenOff ? 0 : dpd,
|
|
||||||
disbursed,
|
|
||||||
outstanding,
|
|
||||||
overdue: dpd > 0 && !writtenOff ? round(Math.min(outstanding, emi * Math.ceil(dpd / 30)), 1) : 0,
|
|
||||||
emi: closed || writtenOff ? 0 : emi,
|
|
||||||
emisPaid,
|
|
||||||
emisLate
|
|
||||||
});
|
|
||||||
loanId++;
|
|
||||||
}
|
|
||||||
addBanking(client, bank);
|
|
||||||
clients.push(client);
|
|
||||||
}
|
|
||||||
return clients;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Savings / current account statement for the last six months and the member's shareholding. */
|
|
||||||
function addBanking(c: DemoClient, bank: () => number): void {
|
|
||||||
const active = c.loans.filter((l) => l.status === 'Active');
|
|
||||||
const emi = active.reduce((s, l) => s + l.emi, 0);
|
|
||||||
const overdue = active.some((l) => l.dpd > 0);
|
|
||||||
const late = c.loans.reduce((s, l) => s + l.emisLate, 0);
|
|
||||||
// Salaried members run a savings account; traders and businesses run a current account.
|
|
||||||
const business = !c.monthlyIncome;
|
|
||||||
const turnover = c.monthlyIncome ?? 150000 + bank() * 650000;
|
|
||||||
const spendRatio = business ? 0.82 + bank() * 0.12 : 0.3 + bank() * 0.3;
|
|
||||||
let returns = overdue ? 1 + Math.floor(bank() * 3) : late > MAX_LATE_EMIS ? 1 : bank() < 0.08 ? 1 : 0;
|
|
||||||
const months: DemoMonth[] = MONTHS.map((month, i) => {
|
|
||||||
const credits = round(turnover * (0.9 + bank() * 0.2) + (business ? 0 : bank() * 3000), 10);
|
|
||||||
// EMIs go out first; household or business spending is a share of what is left.
|
|
||||||
const debits = round(Math.max(0, credits - emi) * Math.min(0.97, spendRatio * (0.9 + bank() * 0.2)) + emi, 10);
|
|
||||||
const r = returns > 0 && (i >= MONTHS.length - returns || bank() < 0.2) ? 1 : 0;
|
|
||||||
returns -= r;
|
|
||||||
return {
|
|
||||||
month,
|
|
||||||
credits,
|
|
||||||
debits: overdue ? Math.max(debits, round(credits * 1.02, 10)) : debits,
|
|
||||||
avgBalance: round(c.savingsBalance * (0.7 + bank() * 0.6), 100),
|
|
||||||
returns: r
|
|
||||||
};
|
|
||||||
});
|
|
||||||
c.accounts.push({
|
|
||||||
type: business ? 'Current' : 'Savings',
|
|
||||||
accountNo: String((business ? 300000 : 200000) + c.id).padStart(9, '0'),
|
|
||||||
months
|
|
||||||
});
|
|
||||||
// Shares linked to borrowing: 2.5% of secured and 5% of unsecured loans. A few members are short.
|
|
||||||
const required = active.reduce((s, l) => s + (l.disbursed * sharePct(l.productId)) / 100, 0);
|
|
||||||
const short = required > 0 && bank() < 0.15;
|
|
||||||
c.shareHeld = MEMBERSHIP_SHARE + ceil100(short ? required * 0.5 : required);
|
|
||||||
}
|
|
||||||
|
|
||||||
const BOOK = buildBook();
|
|
||||||
const AS_ON = '19-10-2026';
|
|
||||||
|
|
||||||
const STAGES: Array<Omit<LoanStageRow, 'loans' | 'outstanding' | 'overdue'>> = [
|
|
||||||
{ stage: 'STANDARD', label: 'Standard', dpdRange: '0', group: 'PERFORMING' },
|
|
||||||
{ stage: 'SMA0', label: 'SMA-0', dpdRange: '1-30', group: 'PERFORMING' },
|
|
||||||
{ stage: 'SMA1', label: 'SMA-1', dpdRange: '31-60', group: 'PERFORMING' },
|
|
||||||
{ stage: 'SMA2', label: 'SMA-2', dpdRange: '61-90', group: 'PERFORMING' },
|
|
||||||
{ stage: 'SUBSTANDARD', label: 'Sub-standard', dpdRange: '91-455', group: 'NPA' },
|
|
||||||
{ stage: 'DOUBTFUL1', label: 'Doubtful D1', dpdRange: '456-820', group: 'NPA' },
|
|
||||||
{ stage: 'DOUBTFUL2', label: 'Doubtful D2', dpdRange: '821-1550', group: 'NPA' },
|
|
||||||
{ stage: 'DOUBTFUL3', label: 'Doubtful D3', dpdRange: 'above 1550', group: 'NPA' },
|
|
||||||
{ stage: 'LOSS', label: 'Loss / written off', dpdRange: 'written off', group: 'CLOSED' },
|
|
||||||
{ stage: 'CLOSED', label: 'Closed', dpdRange: 'repaid', group: 'CLOSED' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** RBI IRAC class of a loan on days past due. */
|
|
||||||
function stageOf(l: DemoLoan): string {
|
|
||||||
if (l.status === 'Written off') {
|
|
||||||
return 'LOSS';
|
|
||||||
}
|
|
||||||
if (l.status === 'Closed') {
|
|
||||||
return 'CLOSED';
|
|
||||||
}
|
|
||||||
const d = l.dpd;
|
|
||||||
if (d <= 0) {
|
|
||||||
return 'STANDARD';
|
|
||||||
}
|
|
||||||
if (d <= 30) {
|
|
||||||
return 'SMA0';
|
|
||||||
}
|
|
||||||
if (d <= 60) {
|
|
||||||
return 'SMA1';
|
|
||||||
}
|
|
||||||
if (d <= 90) {
|
|
||||||
return 'SMA2';
|
|
||||||
}
|
|
||||||
if (d <= 455) {
|
|
||||||
return 'SUBSTANDARD';
|
|
||||||
}
|
|
||||||
if (d <= 820) {
|
|
||||||
return 'DOUBTFUL1';
|
|
||||||
}
|
|
||||||
return d <= 1550 ? 'DOUBTFUL2' : 'DOUBTFUL3';
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelOf(stage: string): string {
|
|
||||||
return STAGES.find((s) => s.stage === stage)?.label ?? stage;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNpa(stage: string): boolean {
|
|
||||||
return STAGES.find((s) => s.stage === stage)?.group === 'NPA';
|
|
||||||
}
|
|
||||||
|
|
||||||
function categoryOf(c: DemoClient): BorrowerCategory {
|
|
||||||
let cat: BorrowerCategory = 'GOOD';
|
|
||||||
for (const l of c.loans) {
|
|
||||||
const st = stageOf(l);
|
|
||||||
if (st === 'LOSS' || isNpa(st)) {
|
|
||||||
return 'BAD';
|
|
||||||
}
|
|
||||||
if (l.status === 'Active' && st !== 'STANDARD') {
|
|
||||||
cat = 'WATCH';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cat;
|
|
||||||
}
|
|
||||||
|
|
||||||
function productName(id: number): string {
|
|
||||||
return DEMO_PRODUCTS.find((p) => p.id === id)?.name ?? String(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shares the member should hold against the running loans. */
|
|
||||||
function shareRequired(c: DemoClient): number {
|
|
||||||
return c.loans
|
|
||||||
.filter((l) => l.status === 'Active')
|
|
||||||
.reduce((s, l) => s + (l.disbursed * sharePct(l.productId)) / 100, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Six-month averages of the operative account. */
|
|
||||||
function statement(c: DemoClient) {
|
|
||||||
const ms = c.accounts.flatMap((a) => a.months);
|
|
||||||
const n = MONTHS.length;
|
|
||||||
const credits = ms.reduce((s, m) => s + m.credits, 0) / n;
|
|
||||||
const debits = ms.reduce((s, m) => s + m.debits, 0) / n;
|
|
||||||
return {
|
|
||||||
type: c.accounts[0]?.type ?? 'Savings',
|
|
||||||
credits,
|
|
||||||
debits,
|
|
||||||
surplus: credits - debits,
|
|
||||||
avgBalance: ms.reduce((s, m) => s + m.avgBalance, 0) / n,
|
|
||||||
returns: ms.reduce((s, m) => s + m.returns, 0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const inr = (n: number) => '₹' + Math.round(n).toLocaleString('en-IN');
|
|
||||||
|
|
||||||
function eligibility(c: DemoClient, p: DemoProduct): ProductEligibility {
|
|
||||||
const passed: string[] = [];
|
|
||||||
const failed: string[] = [];
|
|
||||||
const notes: string[] = [];
|
|
||||||
const active = c.loans.filter((l) => l.status === 'Active');
|
|
||||||
const currentEmi = active.reduce((s, l) => s + l.emi, 0);
|
|
||||||
let limit = p.maxAmount;
|
|
||||||
const paid = c.loans.reduce((s, l) => s + l.emisPaid, 0);
|
|
||||||
const late = c.loans.reduce((s, l) => s + l.emisLate, 0);
|
|
||||||
if (late > MAX_LATE_EMIS) {
|
|
||||||
failed.push(`EMI record: ${late} of ${paid} EMIs paid late (allowed up to ${MAX_LATE_EMIS})`);
|
|
||||||
} else {
|
|
||||||
passed.push(`EMI record: ${paid - late} of ${paid} EMIs paid on time`);
|
|
||||||
}
|
|
||||||
const st = statement(c);
|
|
||||||
if (st.returns > MAX_RETURNS) {
|
|
||||||
failed.push(`${st.returns} cheque / ECS returns in the last 6 months (allowed up to ${MAX_RETURNS})`);
|
|
||||||
} else {
|
|
||||||
passed.push(`${st.returns} cheque / ECS return(s) in the last 6 months`);
|
|
||||||
}
|
|
||||||
if (st.surplus <= 0) {
|
|
||||||
failed.push(`Account spending ${inr(st.debits)} a month is more than the credits ${inr(st.credits)}`);
|
|
||||||
} else {
|
|
||||||
passed.push(
|
|
||||||
`${st.type} account: credits ${inr(st.credits)} − expenses ${inr(st.debits)} = ${inr(st.surplus)} left a month`
|
|
||||||
);
|
|
||||||
if (p.maxFoir !== undefined) {
|
|
||||||
limit = Math.min(limit, principalForEmi(st.surplus, p.annualRate, p.tenureMonths));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p.minAge !== undefined) {
|
|
||||||
if (c.age < p.minAge) {
|
|
||||||
failed.push(`Age ${c.age} is below the minimum ${p.minAge}`);
|
|
||||||
} else {
|
|
||||||
passed.push(`Age ${c.age} (minimum ${p.minAge})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p.maxAgeAtMaturity !== undefined) {
|
|
||||||
const atMaturity = c.age + Math.ceil(p.tenureMonths / 12);
|
|
||||||
if (atMaturity > p.maxAgeAtMaturity) {
|
|
||||||
failed.push(`Age at maturity ${atMaturity} exceeds ${p.maxAgeAtMaturity}`);
|
|
||||||
} else {
|
|
||||||
passed.push(`Age at maturity ${atMaturity} (maximum ${p.maxAgeAtMaturity})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p.minMonthlyIncome !== undefined) {
|
|
||||||
if (!c.monthlyIncome) {
|
|
||||||
failed.push(`Monthly income not recorded (minimum ${inr(p.minMonthlyIncome)})`);
|
|
||||||
} else if (c.monthlyIncome < p.minMonthlyIncome) {
|
|
||||||
failed.push(`Monthly income ${inr(c.monthlyIncome)} is below ${inr(p.minMonthlyIncome)}`);
|
|
||||||
} else {
|
|
||||||
passed.push(`Monthly income ${inr(c.monthlyIncome)} (minimum ${inr(p.minMonthlyIncome)})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p.maxLoansPerCustomer !== undefined) {
|
|
||||||
const running = active.filter((l) => l.productId === p.id).length;
|
|
||||||
if (running >= p.maxLoansPerCustomer) {
|
|
||||||
failed.push(`${running} loan(s) of this product already running (maximum ${p.maxLoansPerCustomer})`);
|
|
||||||
} else {
|
|
||||||
passed.push(`${running} loan(s) of this product running (maximum ${p.maxLoansPerCustomer})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p.maxFoir !== undefined && c.monthlyIncome) {
|
|
||||||
const room = (c.monthlyIncome * p.maxFoir) / 100 - currentEmi;
|
|
||||||
if (room <= 0) {
|
|
||||||
failed.push(`Current EMIs ${inr(currentEmi)} already use the FOIR limit of ${p.maxFoir}% of income`);
|
|
||||||
} else {
|
|
||||||
const byFoir = principalForEmi(room, p.annualRate, p.tenureMonths);
|
|
||||||
passed.push(`FOIR ${p.maxFoir}%: EMI room ${inr(room)} a month supports up to ${inr(byFoir)}`);
|
|
||||||
limit = Math.min(limit, byFoir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p.ltvPct !== undefined) {
|
|
||||||
if (c.fdBalance <= 0) {
|
|
||||||
failed.push('No FD / RD to pledge');
|
|
||||||
} else {
|
|
||||||
const byDeposit = (c.fdBalance * p.ltvPct) / 100;
|
|
||||||
passed.push(`FD ${inr(c.fdBalance)} at ${p.ltvPct}% allows up to ${inr(byDeposit)}`);
|
|
||||||
limit = Math.min(limit, byDeposit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p.id === 4) {
|
|
||||||
notes.push('Final amount depends on the gold valued at the branch');
|
|
||||||
}
|
|
||||||
let eligible = failed.length === 0;
|
|
||||||
if (eligible && limit < p.minAmount) {
|
|
||||||
failed.push(`Eligible amount ${inr(limit)} is below the product minimum ${inr(p.minAmount)}`);
|
|
||||||
eligible = false;
|
|
||||||
}
|
|
||||||
const amount = eligible ? round(limit, 1000) : undefined;
|
|
||||||
const pct = p.secured ? 2.5 : 5;
|
|
||||||
const shortfall = Math.max(0, shareRequired(c) - (c.shareHeld - MEMBERSHIP_SHARE));
|
|
||||||
const shareToBuy = amount ? ceil100((amount * pct) / 100 + shortfall) : undefined;
|
|
||||||
if (amount) {
|
|
||||||
notes.push(
|
|
||||||
`Shares of ${inr((amount * pct) / 100)} to be taken (${pct}% of the loan, ${p.secured ? 'secured' : 'unsecured'})`
|
|
||||||
);
|
|
||||||
if (shortfall > 0) {
|
|
||||||
notes.push(`Existing share shortfall of ${inr(shortfall)} on running loans is collected too`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notes.push('CIBIL score not checked in this demo');
|
|
||||||
return {
|
|
||||||
productId: p.id,
|
|
||||||
productName: p.name,
|
|
||||||
eligible,
|
|
||||||
eligibleAmount: amount,
|
|
||||||
sharePct: pct,
|
|
||||||
shareToBuy,
|
|
||||||
netInHand: amount && shareToBuy !== undefined ? amount - shareToBuy : undefined,
|
|
||||||
minAmount: p.minAmount,
|
|
||||||
maxAmount: p.maxAmount,
|
|
||||||
passed,
|
|
||||||
failed,
|
|
||||||
notes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toBorrower(c: DemoClient, detail: boolean): Borrower {
|
|
||||||
const category = categoryOf(c);
|
|
||||||
const active = c.loans.filter((l) => l.status === 'Active');
|
|
||||||
const order = STAGES.map((s) => s.stage);
|
|
||||||
const worst = active.map(stageOf).sort((a, b) => order.indexOf(b) - order.indexOf(a))[0];
|
|
||||||
const elig = category === 'GOOD' ? DEMO_PRODUCTS.map((p) => eligibility(c, p)) : [];
|
|
||||||
const loans: BorrowerLoan[] = c.loans.map((l) => ({
|
|
||||||
loanId: l.id,
|
|
||||||
accountNo: l.accountNo,
|
|
||||||
productName: productName(l.productId),
|
|
||||||
status: l.status,
|
|
||||||
stage: labelOf(stageOf(l)),
|
|
||||||
dpd: l.dpd,
|
|
||||||
emisPaid: l.emisPaid,
|
|
||||||
emisLate: l.emisLate,
|
|
||||||
disbursed: l.disbursed,
|
|
||||||
outstanding: l.outstanding,
|
|
||||||
overdue: l.overdue,
|
|
||||||
sharePct: sharePct(l.productId),
|
|
||||||
shareRequired: l.status === 'Active' ? (l.disbursed * sharePct(l.productId)) / 100 : 0
|
|
||||||
}));
|
|
||||||
const st = statement(c);
|
|
||||||
return {
|
|
||||||
clientId: c.id,
|
|
||||||
clientName: c.name,
|
|
||||||
clientAccountNo: c.accountNo,
|
|
||||||
category,
|
|
||||||
activeLoans: active.length,
|
|
||||||
closedLoans: c.loans.filter((l) => l.status === 'Closed').length,
|
|
||||||
writtenOffLoans: c.loans.filter((l) => l.status === 'Written off').length,
|
|
||||||
outstanding: active.reduce((s, l) => s + l.outstanding, 0),
|
|
||||||
maxDpd: active.reduce((m, l) => Math.max(m, l.dpd), 0),
|
|
||||||
worstStage: worst ? labelOf(worst) : undefined,
|
|
||||||
monthlyIncome: c.monthlyIncome,
|
|
||||||
age: c.age,
|
|
||||||
depositBalance: c.fdBalance + c.savingsBalance,
|
|
||||||
currentEmi: active.reduce((s, l) => s + l.emi, 0),
|
|
||||||
eligibleProducts: elig.filter((e) => e.eligible).length,
|
|
||||||
emisPaid: c.loans.reduce((s, l) => s + l.emisPaid, 0),
|
|
||||||
emisLate: c.loans.reduce((s, l) => s + l.emisLate, 0),
|
|
||||||
shareHeld: c.shareHeld,
|
|
||||||
shareRequired: MEMBERSHIP_SHARE + shareRequired(c),
|
|
||||||
accountType: st.type,
|
|
||||||
avgCredits: st.credits,
|
|
||||||
avgDebits: st.debits,
|
|
||||||
returns: st.returns,
|
|
||||||
accounts: detail ? c.accounts : undefined,
|
|
||||||
loans: detail ? loans : undefined,
|
|
||||||
eligibility: detail ? elig : undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function demoSummary(): LoanEligibilitySummary {
|
|
||||||
const all = BOOK.flatMap((c) => c.loans);
|
|
||||||
const stages: LoanStageRow[] = STAGES.map((s) => {
|
|
||||||
const ls = all.filter((l) => stageOf(l) === s.stage);
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
loans: ls.length,
|
|
||||||
outstanding: ls.reduce((a, l) => a + l.outstanding, 0),
|
|
||||||
overdue: ls.reduce((a, l) => a + l.overdue, 0)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const active = all.filter((l) => l.status === 'Active');
|
|
||||||
const npa = active.filter((l) => isNpa(stageOf(l)));
|
|
||||||
const activeOut = active.reduce((a, l) => a + l.outstanding, 0);
|
|
||||||
const npaOut = npa.reduce((a, l) => a + l.outstanding, 0);
|
|
||||||
const cats = BOOK.map(categoryOf);
|
|
||||||
const linked = BOOK.filter((c) => shareRequired(c) > 0);
|
|
||||||
const shortOf = (c: DemoClient) => Math.max(0, shareRequired(c) - (c.shareHeld - MEMBERSHIP_SHARE));
|
|
||||||
return {
|
|
||||||
shareRequired: linked.reduce((a, c) => a + shareRequired(c), 0),
|
|
||||||
shareHeld: linked.reduce((a, c) => a + c.shareHeld - MEMBERSHIP_SHARE, 0),
|
|
||||||
shareShortMembers: linked.filter((c) => shortOf(c) > 0).length,
|
|
||||||
shareShortfall: linked.reduce((a, c) => a + shortOf(c), 0),
|
|
||||||
asOnDate: AS_ON,
|
|
||||||
activeLoans: active.length,
|
|
||||||
activeOutstanding: activeOut,
|
|
||||||
performingLoans: active.length - npa.length,
|
|
||||||
performingOutstanding: activeOut - npaOut,
|
|
||||||
npaLoans: npa.length,
|
|
||||||
npaOutstanding: npaOut,
|
|
||||||
grossNpaPct: activeOut > 0 ? Math.round((npaOut / activeOut) * 10000) / 100 : 0,
|
|
||||||
closedLoans: all.filter((l) => l.status === 'Closed').length,
|
|
||||||
writtenOffLoans: all.filter((l) => l.status === 'Written off').length,
|
|
||||||
writtenOffAmount: all.filter((l) => l.status === 'Written off').reduce((a, l) => a + l.disbursed * 0.6, 0),
|
|
||||||
goodBorrowers: cats.filter((c) => c === 'GOOD').length,
|
|
||||||
watchBorrowers: cats.filter((c) => c === 'WATCH').length,
|
|
||||||
badBorrowers: cats.filter((c) => c === 'BAD').length,
|
|
||||||
stages,
|
|
||||||
products: DEMO_PRODUCTS.map((p) => {
|
|
||||||
const ls = active.filter((l) => l.productId === p.id);
|
|
||||||
const bad = ls.filter((l) => isNpa(stageOf(l)));
|
|
||||||
return {
|
|
||||||
productId: p.id,
|
|
||||||
productName: p.name,
|
|
||||||
activeLoans: ls.length,
|
|
||||||
performingLoans: ls.length - bad.length,
|
|
||||||
npaLoans: bad.length,
|
|
||||||
outstanding: ls.reduce((a, l) => a + l.outstanding, 0),
|
|
||||||
npaOutstanding: bad.reduce((a, l) => a + l.outstanding, 0)
|
|
||||||
};
|
|
||||||
}).sort((a, b) => b.outstanding - a.outstanding),
|
|
||||||
notes: [
|
|
||||||
'Demo data: the figures are illustrative, not the bank’s books.',
|
|
||||||
'Share linking to borrowing: members hold shares of 5% of unsecured and 2.5% of secured loans (UCBs keeping CRAR of 12% on a continuous basis are exempt).',
|
|
||||||
'Classification as per RBI IRAC norms on days past due (90+ days = NPA; Sub-standard up to 12 months as NPA, then Doubtful D1 / D2 / D3).',
|
|
||||||
'Eligibility uses each product’s rules: age, age at maturity, monthly income, FOIR, loans per customer and, for loans against FD, the LTV on the deposit.'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function demoBorrowers(category: BorrowerCategory | '', search: string): Borrower[] {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
return BOOK.filter((c) => !category || categoryOf(c) === category)
|
|
||||||
.filter((c) => !q || c.name.toLowerCase().includes(q) || c.accountNo.includes(q))
|
|
||||||
.map((c) => toBorrower(c, false))
|
|
||||||
.sort(
|
|
||||||
(a, b) =>
|
|
||||||
b.maxDpd - a.maxDpd || b.eligibleProducts - a.eligibleProducts || a.clientName.localeCompare(b.clientName)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function demoBorrower(clientId: number): Borrower | undefined {
|
|
||||||
const c = BOOK.find((x) => x.id === clientId);
|
|
||||||
return c ? toBorrower(c, true) : undefined;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { NgModule } from '@angular/core';
|
|
||||||
import { LoanEligibilityRoutingModule } from './loan-eligibility-routing.module';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [LoanEligibilityRoutingModule]
|
|
||||||
})
|
|
||||||
export class LoanEligibilityModule {}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user