Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
357f666421 | ||
|
|
3e5cc26e49 | ||
|
|
c3713f8a90 | ||
|
|
f804d356d7 | ||
|
|
1daa8e60a0 | ||
|
|
4d205672df |
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,16 +158,6 @@ export class AmlService {
|
|||||||
return this.http.get<any>('/aml/cc-tracking', { params: new HttpParams().set('days', String(days)) });
|
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,
|
||||||
|
|||||||
@@ -132,8 +132,8 @@
|
|||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.br"
|
[(ngModel)]="row.br"
|
||||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
[disabled]="branchSelectOptions.length <= 1"
|
||||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
@for (b of branchSelectOptions; track b.id) {
|
@for (b of branchSelectOptions; track b.id) {
|
||||||
@@ -1626,8 +1626,8 @@
|
|||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.br"
|
[(ngModel)]="row.br"
|
||||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
[disabled]="branchSelectOptions.length <= 1"
|
||||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
@for (b of branchSelectOptions; track b.id) {
|
@for (b of branchSelectOptions; track b.id) {
|
||||||
|
|||||||
@@ -1880,7 +1880,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();
|
||||||
@@ -2712,9 +2711,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
branchNameFor(branchId: number): string {
|
branchNameFor(branchId: number): string {
|
||||||
const seen = this.seenBranches.find((b) => b.id === branchId);
|
return (
|
||||||
if (seen) return seen.name;
|
this.seenBranches.find((b) => b.id === branchId)?.name ??
|
||||||
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
|
this.branches.find((b) => b.id === branchId)?.label ??
|
||||||
|
`Branch ${branchId}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4064,8 +4065,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((res: any) => {
|
.subscribe((res: any) => {
|
||||||
if (token !== this.searchToken) return; // stale result — newer search started
|
if (token !== this.searchToken) return; // stale result — newer search started
|
||||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
const found: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||||
const f = cfg.matchField;
|
const f = cfg.matchField;
|
||||||
|
// SD, FD and RD share one list that returns any kind of deposit account: keep only the kind chosen in A/C TP.
|
||||||
|
const allItems =
|
||||||
|
cfg.depositTypeId != null ? found.filter((x: any) => x?.depositType?.id === cfg.depositTypeId) : found;
|
||||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||||
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
||||||
@@ -4095,7 +4099,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loadingAccount = false;
|
this.loadingAccount = false;
|
||||||
this.accountError = 'Account not found for: ' + acNo;
|
// The number may exist as another kind of account (an FD typed under SD, say): say so, so the clerk can fix A/C TP.
|
||||||
|
const otherKind =
|
||||||
|
cfg.depositTypeId != null
|
||||||
|
? found.find(
|
||||||
|
(x: any) =>
|
||||||
|
x?.depositType?.id !== cfg.depositTypeId &&
|
||||||
|
(String(x[f] ?? '').trim() === String(acNo).trim() || String(x[f] ?? '').endsWith(acNo))
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
this.accountError = otherKind
|
||||||
|
? `${acTyp} account not found for: ${acNo} (this number is a ${otherKind.depositType?.value ?? 'different kind of'} account - change A/C TP)`
|
||||||
|
: `${acTyp} account not found for: ${acNo}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4159,6 +4174,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
|
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
|
||||||
* for a transaction; a Header can never receive a posting, so it should never be offered. */
|
* for a transaction; a Header can never receive a posting, so it should never be offered. */
|
||||||
extraListQueryParams?: string;
|
extraListQueryParams?: string;
|
||||||
|
/** SD, FD and RD are all served by the one /savingsaccounts list, which answers with a deposit
|
||||||
|
* account of any kind for a number. This is the depositType.id the chosen A/C type accepts
|
||||||
|
* (100 savings, 200 fixed, 300 recurring), so a number of another kind is "not found" here. */
|
||||||
|
depositTypeId?: number;
|
||||||
} {
|
} {
|
||||||
const t = acTyp?.toUpperCase();
|
const t = acTyp?.toUpperCase();
|
||||||
if (t === 'LA') {
|
if (t === 'LA') {
|
||||||
@@ -4239,6 +4258,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
list: '/savingsaccounts',
|
list: '/savingsaccounts',
|
||||||
detail: '/fixeddepositaccounts',
|
detail: '/fixeddepositaccounts',
|
||||||
|
depositTypeId: 200,
|
||||||
nominee: 'FD',
|
nominee: 'FD',
|
||||||
search: 'fixeddeposit',
|
search: 'fixeddeposit',
|
||||||
siAccountType: 2,
|
siAccountType: 2,
|
||||||
@@ -4256,6 +4276,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
list: '/savingsaccounts',
|
list: '/savingsaccounts',
|
||||||
detail: '/recurringdepositaccounts',
|
detail: '/recurringdepositaccounts',
|
||||||
|
depositTypeId: 300,
|
||||||
nominee: 'RD',
|
nominee: 'RD',
|
||||||
search: 'recurringdeposit',
|
search: 'recurringdeposit',
|
||||||
siAccountType: 2,
|
siAccountType: 2,
|
||||||
@@ -4273,6 +4294,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
list: '/savingsaccounts',
|
list: '/savingsaccounts',
|
||||||
detail: '/savingsaccounts',
|
detail: '/savingsaccounts',
|
||||||
|
depositTypeId: 100,
|
||||||
nominee: 'SB',
|
nominee: 'SB',
|
||||||
search: 'saving',
|
search: 'saving',
|
||||||
siAccountType: 2,
|
siAccountType: 2,
|
||||||
@@ -4960,13 +4982,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return Number(mine) === Number(accountOffice);
|
return Number(mine) === Number(accountOffice);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The bank's branches, for each row's BRANCH selector. Empty until /offices answers. */
|
/** What the BRANCH selector offers: the offices this login may use, plus the branches of the accounts picked here. */
|
||||||
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 }[] {
|
get branchSelectOptions(): { id: number; name: string }[] {
|
||||||
const base = this.branchOptions.length ? this.branchOptions : TransactionEntryComponent.LEGACY_BRANCHES;
|
const merged = this.branches.map(
|
||||||
const merged = base.map((branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? branch);
|
(branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? { id: branch.id, name: branch.label }
|
||||||
|
);
|
||||||
for (const seen of this.seenBranches) {
|
for (const seen of this.seenBranches) {
|
||||||
if (!merged.some((branch) => branch.id === seen.id)) merged.push(seen);
|
if (!merged.some((branch) => branch.id === seen.id)) merged.push(seen);
|
||||||
}
|
}
|
||||||
@@ -4976,27 +4996,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
/** Branches learned from the accounts picked on this screen; their names are real, whatever the office list says. */
|
/** 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 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
|
* 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
|
* to the client's own branch and an account opened at another branch read as the client's. The client's account list
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -846,11 +846,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([])));
|
||||||
@@ -1146,8 +1141,8 @@ export class ClientsService {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
officerName?: string;
|
officerName?: string;
|
||||||
sendAt?: string;
|
sendAt?: string;
|
||||||
}): Observable<{ queued: number; skipped: number }> {
|
}): Observable<{ queued: number; skipped: number; unreachable: number }> {
|
||||||
return this.http.post<{ queued: number; skipped: number }>('/kyc/notice-queue', body);
|
return this.http.post<{ queued: number; skipped: number; unreachable: number }>('/kyc/notice-queue', body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Queued notices, newest first; status narrows to PENDING, SENT, FAILED or CANCELLED. */
|
/** Queued notices, newest first; status narrows to PENDING, SENT, FAILED or CANCELLED. */
|
||||||
@@ -1161,9 +1156,63 @@ export class ClientsService {
|
|||||||
return this.http.post(`/kyc/notice-queue/${id}?command=${command}`, {});
|
return this.http.post(`/kyc/notice-queue/${id}?command=${command}`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What each of these customers has been sent so far: the latest notice, how many, and which channels are done. */
|
||||||
|
getKycContactSummary(clientIds: number[]): Observable<
|
||||||
|
{
|
||||||
|
clientId: number;
|
||||||
|
count: number;
|
||||||
|
lastAt: string;
|
||||||
|
lastChannel: string;
|
||||||
|
lastStatus: string;
|
||||||
|
doneChannels: string[];
|
||||||
|
balance?: number;
|
||||||
|
hasMobile?: boolean;
|
||||||
|
hasEmail?: boolean;
|
||||||
|
mobileMasked?: string | null;
|
||||||
|
emailMasked?: string | null;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
return this.http.get<
|
||||||
|
{
|
||||||
|
clientId: number;
|
||||||
|
count: number;
|
||||||
|
lastAt: string;
|
||||||
|
lastChannel: string;
|
||||||
|
lastStatus: string;
|
||||||
|
doneChannels: string[];
|
||||||
|
hasMobile?: boolean;
|
||||||
|
hasEmail?: boolean;
|
||||||
|
mobileMasked?: string | null;
|
||||||
|
emailMasked?: string | null;
|
||||||
|
}[]
|
||||||
|
>('/kyc/contact-audit/clients', {
|
||||||
|
params: new HttpParams().set('ids', clientIds.join(',')),
|
||||||
|
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notices per customer (counts, outcomes, the latest), most recently contacted first, a page at a time. */
|
||||||
|
getKycContactAuditCustomers(
|
||||||
|
filter: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
channel?: string;
|
||||||
|
status?: string;
|
||||||
|
search?: string;
|
||||||
|
real?: boolean;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
} = {}
|
||||||
|
): Observable<{ total: number; rows: any[] }> {
|
||||||
|
return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit/customers', {
|
||||||
|
params: this.auditParams(filter),
|
||||||
|
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Notices per day (customers, notices, outcomes), newest day first. */
|
/** Notices per day (customers, notices, outcomes), newest day first. */
|
||||||
getKycContactAuditDays(
|
getKycContactAuditDays(
|
||||||
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string } = {}
|
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string; real?: boolean } = {}
|
||||||
): Observable<any[]> {
|
): Observable<any[]> {
|
||||||
return this.http.get<any[]>('/kyc/contact-audit/days', {
|
return this.http.get<any[]>('/kyc/contact-audit/days', {
|
||||||
params: this.auditParams(filter),
|
params: this.auditParams(filter),
|
||||||
@@ -1179,6 +1228,7 @@ export class ClientsService {
|
|||||||
channel?: string;
|
channel?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
real?: boolean;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
} = {}
|
} = {}
|
||||||
@@ -1189,7 +1239,7 @@ export class ClientsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private auditParams(filter: Record<string, string | number | undefined>): HttpParams {
|
private auditParams(filter: Record<string, string | number | boolean | undefined>): HttpParams {
|
||||||
let params = new HttpParams();
|
let params = new HttpParams();
|
||||||
Object.entries(filter).forEach(
|
Object.entries(filter).forEach(
|
||||||
([
|
([
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -387,6 +400,8 @@ export class ShellComponent implements OnInit, OnDestroy {
|
|||||||
* 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 {}
|
|
||||||
-12
@@ -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>
|
||||||
|
|||||||
-13
@@ -1735,16 +1735,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;
|
|
||||||
}
|
|
||||||
|
|||||||
-32
@@ -31,8 +31,6 @@ import { AddRelatedClientDialogComponent } from './add-related-client-dialog/add
|
|||||||
import { AlertService } from 'app/core/alert/alert.service';
|
import { AlertService } from 'app/core/alert/alert.service';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { catchError } from 'rxjs/operators';
|
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 +58,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;
|
||||||
@@ -225,13 +216,8 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +255,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: [
|
||||||
|
|||||||
+57
-4
@@ -331,12 +331,14 @@
|
|||||||
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
|
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
|
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
|
||||||
<span class="fdrc-maturity-row__label">Interest Earned</span>
|
<span class="fdrc-maturity-row__label">{{
|
||||||
|
isSpecial ? 'Total Interest (paid out monthly)' : 'Interest Earned'
|
||||||
|
}}</span>
|
||||||
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
|
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
|
||||||
<div class="fdrc-maturity-row">
|
<div class="fdrc-maturity-row">
|
||||||
<span class="fdrc-maturity-row__label">Monthly Payout</span>
|
<span class="fdrc-maturity-row__label">Monthly Payout (approx., by days in the month)</span>
|
||||||
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
|
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
|
||||||
₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month
|
₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month
|
||||||
</span>
|
</span>
|
||||||
@@ -351,13 +353,64 @@
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big">
|
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big">
|
||||||
₹ {{ (isSpecial ? principalAmount : matchResult.maturityAmount) | number: '1.2-2' }}
|
₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (isSpecial && specialPreview) {
|
||||||
|
<div class="fdrc-maturity-row">
|
||||||
|
<span class="fdrc-maturity-row__label">Maturity Date</span>
|
||||||
|
<span class="fdrc-maturity-row__value">{{
|
||||||
|
specialPreview.maturityDate | date: 'dd MMM yyyy'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="fdrc-maturity-row">
|
||||||
|
<span class="fdrc-maturity-row__label">Monthly payouts</span>
|
||||||
|
<span class="fdrc-maturity-row__value">{{ specialPreview.numberOfPayouts }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="fdrc-maturity-row">
|
||||||
|
<span class="fdrc-maturity-row__label">Principal + all payouts</span>
|
||||||
|
<span class="fdrc-maturity-row__value"
|
||||||
|
>₹ {{ specialPreview.totalValueAtMaturity | number: '1.2-2' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (isSpecial && specialPreview) {
|
||||||
|
<div class="fdrc-schedule">
|
||||||
|
<div class="fdrc-schedule__head">
|
||||||
|
<span><i class="fas fa-calendar-alt"></i> Monthly Interest Payout Schedule</span>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
class="fdrc-schedule__toggle"
|
||||||
|
(click)="openSpecialSchedule()"
|
||||||
|
>
|
||||||
|
View payout schedule ({{ specialPreview.numberOfPayouts }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="fdrc-schedule__note">
|
||||||
|
From {{ specialPreview.startDate | date: 'dd MMM yyyy' }} (the day the account is opened) to
|
||||||
|
{{ specialPreview.maturityDate | date: 'dd MMM yyyy' }}, worked out by the account engine from the
|
||||||
|
product.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (!matchResult.maturityAmount) {
|
@if (specialPreviewLoading) {
|
||||||
|
<div class="fdrc-payout-hint">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i>
|
||||||
|
Working out the maturity from the product…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (specialPreviewError) {
|
||||||
|
<div class="fdrc-payout-hint fdrc-payout-hint--error">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
{{ specialPreviewError }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!matchResult.maturityAmount && !specialPreviewLoading && !specialPreviewError) {
|
||||||
<div class="fdrc-payout-hint">
|
<div class="fdrc-payout-hint">
|
||||||
<i class="fas fa-lightbulb"></i>
|
<i class="fas fa-lightbulb"></i>
|
||||||
Enter a deposit amount above to see the maturity calculation.
|
Enter a deposit amount above to see the maturity calculation.
|
||||||
|
|||||||
+69
@@ -1229,3 +1229,72 @@ $red-light: #fee2e2;
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* FD Special: payout schedule from the backend preview */
|
||||||
|
.fdrc-schedule {
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #166534;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__note {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wrap {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
tfoot td {
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f0fdf4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdrc-payout-hint--error {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|||||||
+140
-79
@@ -36,12 +36,39 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
|
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { FixedDepositsService } from '../../fixed-deposits.service';
|
import { FixedDepositsService } from '../../fixed-deposits.service';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { InterestScheduleDialogComponent } from '../../fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
|
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
|
||||||
|
|
||||||
export type FdPeriodUnit = 'Days' | 'Months' | 'Years';
|
export type FdPeriodUnit = 'Days' | 'Months' | 'Years';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yyyy-MM-dd of the calendar day as the user sees it. toISOString() gives the UTC day instead, which in India is the day
|
||||||
|
* before for any date at local midnight - so a deposit opened on the 21st was quoted from the 20th.
|
||||||
|
*/
|
||||||
|
function localIsoDate(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FD quote from POST fixeddepositaccounts/account-preview (worked out by the account engine, every FD type). */
|
||||||
|
export interface SpecialPayoutPreview {
|
||||||
|
fdType: string;
|
||||||
|
depositAmount: number;
|
||||||
|
annualInterestRate: number;
|
||||||
|
seniorCitizen: boolean;
|
||||||
|
startDate: string;
|
||||||
|
maturityDate: string;
|
||||||
|
numberOfPayouts: number;
|
||||||
|
fullMonthPayout: number;
|
||||||
|
totalInterest: number;
|
||||||
|
maturityAmount: number;
|
||||||
|
totalValueAtMaturity: number;
|
||||||
|
periods: { period: number; fromDate: string; toDate: string; days: number; interestAmount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SlabMatchResult {
|
export interface SlabMatchResult {
|
||||||
slab: any;
|
slab: any;
|
||||||
slabIndex: number;
|
slabIndex: number;
|
||||||
@@ -87,6 +114,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
private clientsService = inject(ClientsService);
|
private clientsService = inject(ClientsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private cdr = inject(ChangeDetectorRef);
|
private cdr = inject(ChangeDetectorRef);
|
||||||
|
private dialog = inject(MatDialog);
|
||||||
|
|
||||||
@Input() fixedDepositsAccountTemplate: any;
|
@Input() fixedDepositsAccountTemplate: any;
|
||||||
@Input() fixedDepositsAccountProductTemplate: any;
|
@Input() fixedDepositsAccountProductTemplate: any;
|
||||||
@@ -163,6 +191,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
matchResult: SlabMatchResult | null = null;
|
matchResult: SlabMatchResult | null = null;
|
||||||
|
/** FD Special only: the backend's quote and payout schedule for the tenure and amount entered. */
|
||||||
|
specialPreview: SpecialPayoutPreview | null = null;
|
||||||
|
specialPreviewLoading = false;
|
||||||
|
specialPreviewError: string | null = null;
|
||||||
validationWarning: string | null = null;
|
validationWarning: string | null = null;
|
||||||
|
|
||||||
// ── FD Type labels ──────────────────────────────────────────────────────
|
// ── FD Type labels ──────────────────────────────────────────────────────
|
||||||
@@ -270,6 +302,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
calculateRate() {
|
calculateRate() {
|
||||||
this.validationWarning = null;
|
this.validationWarning = null;
|
||||||
this.matchResult = null;
|
this.matchResult = null;
|
||||||
|
this.specialPreview = null;
|
||||||
|
this.specialPreviewError = null;
|
||||||
|
|
||||||
if (!this.tenureValue || this.tenureValue <= 0) {
|
if (!this.tenureValue || this.tenureValue <= 0) {
|
||||||
this.validationWarning = 'Please enter a valid tenure value greater than 0.';
|
this.validationWarning = 'Please enter a valid tenure value greater than 0.';
|
||||||
@@ -334,30 +368,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
||||||
|
|
||||||
if (this.principalAmount && this.principalAmount > 0) {
|
if (this.principalAmount && this.principalAmount > 0) {
|
||||||
// Years and Months convert exactly; a Days tenure is sent as days and never converted.
|
|
||||||
let tenureInMonths: number | undefined;
|
|
||||||
let tenureInDays: number | undefined;
|
|
||||||
if (this.tenurePeriodUnit === 'Years') {
|
|
||||||
tenureInMonths = this.tenureValue * 12;
|
|
||||||
} else if (this.tenurePeriodUnit === 'Months') {
|
|
||||||
tenureInMonths = this.tenureValue;
|
|
||||||
} else {
|
|
||||||
// Sent as days, whatever the count. Converting to whole months silently dropped the
|
|
||||||
// remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the
|
|
||||||
// screen still said "200 Days" above the figure. The backend takes days directly and
|
|
||||||
// works the calendar out itself.
|
|
||||||
tenureInDays = enteredDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual)
|
|
||||||
const compMap: Record<number, number> = { 1: 0, 4: 1, 5: 3, 6: 6, 7: 12 };
|
|
||||||
const compId = this.fixedDepositsAccountProductTemplate?.interestCompoundingPeriodType?.id ?? 4;
|
|
||||||
const compMonths = compMap[compId] ?? 1;
|
|
||||||
|
|
||||||
// Map posting period type id → months
|
|
||||||
const postId = this.fixedDepositsAccountProductTemplate?.interestPostingPeriodType?.id ?? 4;
|
|
||||||
const postMonths = compMap[postId] ?? 1;
|
|
||||||
|
|
||||||
const emitResult = () => {
|
const emitResult = () => {
|
||||||
if (frequencyId !== null) {
|
if (frequencyId !== null) {
|
||||||
this.tenureSelected.emit({
|
this.tenureSelected.emit({
|
||||||
@@ -375,7 +385,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
// Get submittedOnDate — Input takes priority over template data
|
// Get submittedOnDate — Input takes priority over template data
|
||||||
let submittedOnDateStr: string;
|
let submittedOnDateStr: string;
|
||||||
if (this.submittedOnDate) {
|
if (this.submittedOnDate) {
|
||||||
submittedOnDateStr = this.submittedOnDate.toISOString().split('T')[0];
|
submittedOnDateStr = localIsoDate(this.submittedOnDate);
|
||||||
} else {
|
} else {
|
||||||
const submittedOnDateRaw =
|
const submittedOnDateRaw =
|
||||||
this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ??
|
this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ??
|
||||||
@@ -384,63 +394,18 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
const d = Array.isArray(submittedOnDateRaw)
|
const d = Array.isArray(submittedOnDateRaw)
|
||||||
? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2])
|
? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2])
|
||||||
: new Date(submittedOnDateRaw);
|
: new Date(submittedOnDateRaw);
|
||||||
submittedOnDateStr = !isNaN(d.getTime())
|
submittedOnDateStr = !isNaN(d.getTime()) ? localIsoDate(d) : localIsoDate(new Date());
|
||||||
? d.toISOString().split('T')[0]
|
|
||||||
: new Date().toISOString().split('T')[0];
|
|
||||||
} else {
|
} else {
|
||||||
submittedOnDateStr = new Date().toISOString().split('T')[0];
|
submittedOnDateStr = localIsoDate(new Date());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const daysInYearVal =
|
// The whole quote - rate, maturity date, maturity amount and, for FD Special, the month-by-month payout
|
||||||
this.fixedDepositsAccountProductTemplate?.interestCalculationDaysInYearType?.id === 360 ? 360 : 365;
|
// schedule - comes from the backend, which builds the would-be account from the product exactly as Submit does
|
||||||
|
// (its posting periods, compounding, days in year, rate card, senior-citizen rate) and runs the account's own
|
||||||
// Always use backend API for accurate calculation (including daily compounding with 0)
|
// interest engine. Nothing is worked out here: on an error the quote is simply not shown, never estimated.
|
||||||
this.fixedDepositsService
|
if (frequencyId !== null) {
|
||||||
.calculateFixedDepositInterest({
|
this.loadAccountPreview(result, rate, submittedOnDateStr, frequencyId, emitResult);
|
||||||
principalAmount: this.principalAmount,
|
}
|
||||||
annualInterestRate: rate,
|
|
||||||
tenureInMonths,
|
|
||||||
tenureInDays,
|
|
||||||
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
|
|
||||||
interestPostingPeriodInMonths: postMonths,
|
|
||||||
submittedOnDate: submittedOnDateStr,
|
|
||||||
daysInYear: daysInYearVal
|
|
||||||
})
|
|
||||||
.subscribe({
|
|
||||||
next: (apiResult: any) => {
|
|
||||||
result.maturityAmount = apiResult.maturityAmount;
|
|
||||||
result.interestEarned =
|
|
||||||
apiResult.totalInterest ?? Math.round((apiResult.maturityAmount - this.principalAmount) * 100) / 100;
|
|
||||||
this.matchResult = { ...result };
|
|
||||||
emitResult();
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
// Fallback if API fails: frontend formula
|
|
||||||
const r = rate / 100;
|
|
||||||
if (compMonths === 0) {
|
|
||||||
// Daily compounding fallback
|
|
||||||
result.maturityAmount =
|
|
||||||
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
|
|
||||||
} else {
|
|
||||||
const n = 12 / compMonths;
|
|
||||||
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
|
|
||||||
const rPerCompounding = rate / (n * 100);
|
|
||||||
// Whole compounding periods, then simple interest on the days left over. Flooring the
|
|
||||||
// periods and stopping there dropped the remainder entirely: a 200-day deposit
|
|
||||||
// compounding monthly quoted only six months of interest and lost the other twenty
|
|
||||||
// days, which is the depositor's money.
|
|
||||||
const wholePeriods = Math.floor(effectiveMonths / compMonths);
|
|
||||||
const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods);
|
|
||||||
const leftoverDays = enteredDays - wholePeriods * compMonths * 30;
|
|
||||||
const leftoverInterest =
|
|
||||||
leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0;
|
|
||||||
result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100;
|
|
||||||
}
|
|
||||||
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
|
|
||||||
this.matchResult = { ...result };
|
|
||||||
emitResult();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
this.matchResult = result;
|
this.matchResult = result;
|
||||||
if (frequencyId !== null) {
|
if (frequencyId !== null) {
|
||||||
@@ -457,6 +422,96 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The backend's payout schedule in the same popup the account itself uses, in its quote (preview) mode. */
|
||||||
|
openSpecialSchedule(): void {
|
||||||
|
if (!this.specialPreview) return;
|
||||||
|
this.dialog.open(InterestScheduleDialogComponent, {
|
||||||
|
width: '760px',
|
||||||
|
data: {
|
||||||
|
preview: true,
|
||||||
|
currencyCode: this.fixedDepositsAccountProductTemplate?.currency?.code ?? 'INR',
|
||||||
|
note:
|
||||||
|
'Worked out by the account engine from the product, starting ' +
|
||||||
|
this.specialPreview.startDate +
|
||||||
|
' (the day the account is opened) and ending the day before maturity on ' +
|
||||||
|
this.specialPreview.maturityDate +
|
||||||
|
'. The first and last periods are part-months; each pays for the days it covers.',
|
||||||
|
schedule: this.specialPreview.periods.map((p) => ({
|
||||||
|
fromDate: p.fromDate,
|
||||||
|
toDate: p.toDate,
|
||||||
|
days: p.days,
|
||||||
|
interestAmount: Number(p.interestAmount),
|
||||||
|
paid: false
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the backend for the quote (POST fixeddepositaccounts/account-preview) with the same terms Submit sends: the
|
||||||
|
* product's own interest terms, and the senior-citizen rate the same way Submit overrides it. The rate, maturity
|
||||||
|
* amount and interest shown are the backend's, so they match the account once it is created.
|
||||||
|
*/
|
||||||
|
private loadAccountPreview(
|
||||||
|
result: SlabMatchResult,
|
||||||
|
rate: number,
|
||||||
|
submittedOnDateStr: string,
|
||||||
|
frequencyId: number,
|
||||||
|
emitResult: () => void
|
||||||
|
): void {
|
||||||
|
const pt = this.fixedDepositsAccountProductTemplate ?? {};
|
||||||
|
const body: any = {
|
||||||
|
clientId: pt.clientId ?? this.fixedDepositsAccountTemplate?.clientId,
|
||||||
|
productId: pt.depositProductId ?? pt.productId ?? this.fixedDepositsAccountTemplate?.productId,
|
||||||
|
submittedOnDate: submittedOnDateStr,
|
||||||
|
dateFormat: 'yyyy-MM-dd',
|
||||||
|
locale: 'en',
|
||||||
|
depositAmount: this.principalAmount,
|
||||||
|
depositPeriod: this.tenureValue,
|
||||||
|
depositPeriodFrequencyId: frequencyId
|
||||||
|
};
|
||||||
|
if (pt.nominalAnnualInterestRate != null) body.nominalAnnualInterestRate = pt.nominalAnnualInterestRate;
|
||||||
|
if (pt.interestCompoundingPeriodType?.id != null)
|
||||||
|
body.interestCompoundingPeriodType = pt.interestCompoundingPeriodType.id;
|
||||||
|
if (pt.interestPostingPeriodType?.id != null) body.interestPostingPeriodType = pt.interestPostingPeriodType.id;
|
||||||
|
if (pt.interestCalculationType?.id != null) body.interestCalculationType = pt.interestCalculationType.id;
|
||||||
|
if (pt.interestCalculationDaysInYearType?.id != null)
|
||||||
|
body.interestCalculationDaysInYearType = pt.interestCalculationDaysInYearType.id;
|
||||||
|
if (this.isSeniorCitizen && rate > 0) {
|
||||||
|
body.isSeniorCitizen = true;
|
||||||
|
body.seniorCitizenInterestRate = rate;
|
||||||
|
body.nominalAnnualInterestRate = rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.specialPreview = null;
|
||||||
|
this.specialPreviewError = null;
|
||||||
|
this.specialPreviewLoading = true;
|
||||||
|
this.fixedDepositsService.accountPreview(body).subscribe({
|
||||||
|
next: (preview: SpecialPayoutPreview) => {
|
||||||
|
this.specialPreviewLoading = false;
|
||||||
|
this.specialPreview = preview;
|
||||||
|
result.appliedRate = Number(preview.annualInterestRate);
|
||||||
|
result.interestEarned = Number(preview.totalInterest);
|
||||||
|
result.estimatedMonthlyPayout = preview.fullMonthPayout != null ? Number(preview.fullMonthPayout) : undefined;
|
||||||
|
result.maturityAmount = Number(preview.maturityAmount);
|
||||||
|
this.matchResult = { ...result };
|
||||||
|
emitResult();
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: (e: any) => {
|
||||||
|
this.specialPreviewLoading = false;
|
||||||
|
this.specialPreviewError =
|
||||||
|
e?.error?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
e?.error?.defaultUserMessage ||
|
||||||
|
'Could not work out the maturity. Check the deposit details and try again.';
|
||||||
|
result.maturityAmount = undefined;
|
||||||
|
result.interestEarned = undefined;
|
||||||
|
this.matchResult = { ...result };
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private resolveFrequencyId(unit: FdPeriodUnit): number | null {
|
private resolveFrequencyId(unit: FdPeriodUnit): number | null {
|
||||||
const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? [];
|
const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? [];
|
||||||
const unitLower = unit.toLowerCase();
|
const unitLower = unit.toLowerCase();
|
||||||
@@ -471,12 +526,16 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
this.tenureValue = null;
|
this.tenureValue = null;
|
||||||
this.principalAmount = null;
|
this.principalAmount = null;
|
||||||
this.matchResult = null;
|
this.matchResult = null;
|
||||||
|
this.specialPreview = null;
|
||||||
|
this.specialPreviewError = null;
|
||||||
this.validationWarning = null;
|
this.validationWarning = null;
|
||||||
this.tenurePeriodUnit = this.allowedPeriodUnits[0];
|
this.tenurePeriodUnit = this.allowedPeriodUnits[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
onPeriodUnitChange() {
|
onPeriodUnitChange() {
|
||||||
this.matchResult = null;
|
this.matchResult = null;
|
||||||
|
this.specialPreview = null;
|
||||||
|
this.specialPreviewError = null;
|
||||||
this.validationWarning = null;
|
this.validationWarning = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,6 +583,8 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
|||||||
this.calculateRate();
|
this.calculateRate();
|
||||||
} else {
|
} else {
|
||||||
this.matchResult = null;
|
this.matchResult = null;
|
||||||
|
this.specialPreview = null;
|
||||||
|
this.specialPreviewError = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-5
@@ -326,7 +326,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Inactive</span>
|
<span class="preview-dorm-step__label">Inactive</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (fixedDepositsAccountProductTemplate?.daysToInactive != null) {
|
@if ((fixedDepositsAccountProductTemplate?.daysToInactive ?? null) !== null) {
|
||||||
After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction
|
After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
@@ -340,7 +340,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Dormant</span>
|
<span class="preview-dorm-step__label">Dormant</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (fixedDepositsAccountProductTemplate?.daysToDormancy != null) {
|
@if ((fixedDepositsAccountProductTemplate?.daysToDormancy ?? null) !== null) {
|
||||||
{{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive
|
{{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
<div class="preview-dorm-step__body">
|
<div class="preview-dorm-step__body">
|
||||||
<span class="preview-dorm-step__label">Escheat</span>
|
<span class="preview-dorm-step__label">Escheat</span>
|
||||||
<span class="preview-dorm-step__days">
|
<span class="preview-dorm-step__days">
|
||||||
@if (fixedDepositsAccountProductTemplate?.daysToEscheat != null) {
|
@if ((fixedDepositsAccountProductTemplate?.daysToEscheat ?? null) !== null) {
|
||||||
{{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant
|
{{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant
|
||||||
} @else {
|
} @else {
|
||||||
Not configured
|
Not configured
|
||||||
@@ -458,13 +458,15 @@
|
|||||||
<span class="preview-value">{{ maturitySummary.tenureLabel }}</span>
|
<span class="preview-value">{{ maturitySummary.tenureLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-field">
|
<div class="preview-field">
|
||||||
<span class="preview-label">Estimated Interest Earned</span>
|
<span class="preview-label">{{
|
||||||
|
isSpecial ? 'Total Interest (paid out monthly)' : 'Estimated Interest Earned'
|
||||||
|
}}</span>
|
||||||
<span class="preview-value" style="color: var(--theme-success, #28a745); font-weight: 500">
|
<span class="preview-value" style="color: var(--theme-success, #28a745); font-weight: 500">
|
||||||
+ {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }}
|
+ {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="preview-field" style="grid-column: span 2">
|
<div class="preview-field" style="grid-column: span 2">
|
||||||
<span class="preview-label">Projected Maturity Amount</span>
|
<span class="preview-label">{{ isSpecial ? 'Principal at Maturity' : 'Projected Maturity Amount' }}</span>
|
||||||
<span class="preview-value preview-mono" style="font-weight: 600; font-size: 1.25rem">
|
<span class="preview-value preview-mono" style="font-weight: 600; font-size: 1.25rem">
|
||||||
{{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }}
|
{{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+5
@@ -145,6 +145,11 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
|
|||||||
SPECIAL: 'FD Special (Monthly Interest Payout)'
|
SPECIAL: 'FD Special (Monthly Interest Payout)'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** FD Special pays its interest out monthly; the principal alone comes back at maturity. */
|
||||||
|
get isSpecial(): boolean {
|
||||||
|
return this.fixedDepositsAccountProductTemplate?.fdType === 'SPECIAL';
|
||||||
|
}
|
||||||
|
|
||||||
getFdTypeLabel(fdType: string): string {
|
getFdTypeLabel(fdType: string): string {
|
||||||
return this.fdTypeLabels[fdType] || fdType;
|
return this.fdTypeLabels[fdType] || fdType;
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-15
@@ -11,15 +11,25 @@
|
|||||||
<span class="isd-header__icon"><fa-icon icon="calendar-alt"></fa-icon></span>
|
<span class="isd-header__icon"><fa-icon icon="calendar-alt"></fa-icon></span>
|
||||||
<span class="isd-header__text">
|
<span class="isd-header__text">
|
||||||
<span class="isd-header__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</span>
|
<span class="isd-header__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</span>
|
||||||
<span class="isd-header__sub">{{ paidCount }} of {{ schedule.length }} periods paid so far</span>
|
<span class="isd-header__sub">
|
||||||
|
@if (isPreview) {
|
||||||
|
{{ schedule.length }} monthly payouts
|
||||||
|
} @else {
|
||||||
|
{{ paidCount }} of {{ schedule.length }} periods paid so far
|
||||||
|
}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div mat-dialog-content class="isd-content">
|
<div mat-dialog-content class="isd-content">
|
||||||
<p class="isd-note">
|
<p class="isd-note">
|
||||||
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
||||||
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can vary
|
@if (data.note) {
|
||||||
slightly with the number of days in that month.
|
{{ data.note }}
|
||||||
|
} @else {
|
||||||
|
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can
|
||||||
|
vary slightly with the number of days in that month.
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="isd-scroll">
|
<div class="isd-scroll">
|
||||||
@@ -28,8 +38,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>{{ 'labels.inputs.From Date' | translate }}</th>
|
<th>{{ 'labels.inputs.From Date' | translate }}</th>
|
||||||
<th>{{ 'labels.inputs.To Date' | translate }}</th>
|
<th>{{ 'labels.inputs.To Date' | translate }}</th>
|
||||||
|
@if (isPreview) {
|
||||||
|
<th class="isd-table__amount-col">Days</th>
|
||||||
|
}
|
||||||
<th class="isd-table__amount-col">{{ 'labels.inputs.Interest Amount' | translate }}</th>
|
<th class="isd-table__amount-col">{{ 'labels.inputs.Interest Amount' | translate }}</th>
|
||||||
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
|
@if (!isPreview) {
|
||||||
|
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -37,28 +52,35 @@
|
|||||||
<tr [class.isd-table__row--paid]="period.paid">
|
<tr [class.isd-table__row--paid]="period.paid">
|
||||||
<td>{{ period.fromDate | dateFormat }}</td>
|
<td>{{ period.fromDate | dateFormat }}</td>
|
||||||
<td>{{ period.toDate | dateFormat }}</td>
|
<td>{{ period.toDate | dateFormat }}</td>
|
||||||
|
@if (isPreview) {
|
||||||
|
<td class="isd-table__amount-col">{{ period.days }}</td>
|
||||||
|
}
|
||||||
<td class="isd-table__amount-col">
|
<td class="isd-table__amount-col">
|
||||||
{{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
{{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="isd-table__status-col">
|
@if (!isPreview) {
|
||||||
@if (period.paid) {
|
<td class="isd-table__status-col">
|
||||||
<span class="isd-badge isd-badge--paid">
|
@if (period.paid) {
|
||||||
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
|
<span class="isd-badge isd-badge--paid">
|
||||||
</span>
|
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
|
||||||
} @else {
|
</span>
|
||||||
<span class="isd-badge isd-badge--pending">Pending</span>
|
} @else {
|
||||||
}
|
<span class="isd-badge isd-badge--pending">Pending</span>
|
||||||
</td>
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
|
<td [attr.colspan]="isPreview ? 3 : 2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
|
||||||
<td class="isd-table__amount-col">
|
<td class="isd-table__amount-col">
|
||||||
{{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
{{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
@if (!isPreview) {
|
||||||
|
<td></td>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+13
-2
@@ -26,6 +26,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
* a 24+ month term makes for a long table that would otherwise push every other card on the tab down
|
* a 24+ month term makes for a long table that would otherwise push every other card on the tab down
|
||||||
* the page. Each row also shows whether that period's interest has actually posted yet, not just what
|
* the page. Each row also shows whether that period's interest has actually posted yet, not just what
|
||||||
* the engine projects it to be.
|
* the engine projects it to be.
|
||||||
|
*
|
||||||
|
* Also opened from the account-opening screen with `preview: true`: the backend's quote for an FD Special
|
||||||
|
* not opened yet, so there is no paid / pending status, and each period shows its number of days.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-interest-schedule-dialog',
|
selector: 'mifosx-interest-schedule-dialog',
|
||||||
@@ -46,14 +49,22 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
export class InterestScheduleDialogComponent {
|
export class InterestScheduleDialogComponent {
|
||||||
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
|
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
|
||||||
data: {
|
data: {
|
||||||
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>;
|
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }>;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
|
/** Account-opening quote: no paid / pending status. */
|
||||||
|
preview?: boolean;
|
||||||
|
/** Shown above the table instead of the account note. */
|
||||||
|
note?: string;
|
||||||
} = inject(MAT_DIALOG_DATA);
|
} = inject(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> {
|
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }> {
|
||||||
return this.data.schedule ?? [];
|
return this.data.schedule ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isPreview(): boolean {
|
||||||
|
return !!this.data.preview;
|
||||||
|
}
|
||||||
|
|
||||||
get currencyCode(): string {
|
get currencyCode(): string {
|
||||||
return this.data.currencyCode;
|
return this.data.currencyCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,16 @@ export class FixedDepositsService {
|
|||||||
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FD quote while opening the account, for every FD type: the same body as create (clientId, productId,
|
||||||
|
* submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, interest terms, senior citizen ...). The
|
||||||
|
* backend builds the would-be account from the product, without saving it, and returns the applied rate, maturity
|
||||||
|
* date and amount Submit would store, and the interest periods (FD Special: the monthly payout schedule).
|
||||||
|
*/
|
||||||
|
accountPreview(body: any): Observable<any> {
|
||||||
|
return this.http.post(`/fixeddepositaccounts/account-preview`, body);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
|
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
|
||||||
* configured: backdated to the original maturity date within the grace window, or from today
|
* configured: backdated to the original maturity date within the grace window, or from today
|
||||||
|
|||||||
+4
-3
@@ -110,8 +110,8 @@ export class CreateRecurringDepositsAccountComponent {
|
|||||||
*/
|
*/
|
||||||
setTemplate($event: any) {
|
setTemplate($event: any) {
|
||||||
this.recurringDepositsAccountProductTemplate = $event;
|
this.recurringDepositsAccountProductTemplate = $event;
|
||||||
this.productsService.getFdBaseInterestRates().subscribe({
|
this.productsService.getRdBaseInterestRates().subscribe({
|
||||||
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
|
next: (rates: any) => (this.cbsBaseRates = Array.isArray(rates) ? rates : rates ? [rates] : []),
|
||||||
error: () => (this.cbsBaseRates = [])
|
error: () => (this.cbsBaseRates = [])
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,8 @@ export class CreateRecurringDepositsAccountComponent {
|
|||||||
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
|
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
|
||||||
this.alertService.alert({
|
this.alertService.alert({
|
||||||
type: 'Error',
|
type: 'Error',
|
||||||
message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
|
message:
|
||||||
|
'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -144,8 +144,8 @@ export class EditRecurringDepositAccountComponent {
|
|||||||
|
|
||||||
setTemplate($event: any) {
|
setTemplate($event: any) {
|
||||||
this.recurringDepositsAccountProductTemplate = $event;
|
this.recurringDepositsAccountProductTemplate = $event;
|
||||||
this.productsService.getFdBaseInterestRates().subscribe({
|
this.productsService.getRdBaseInterestRates().subscribe({
|
||||||
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
|
next: (rates: any) => (this.cbsBaseRates = Array.isArray(rates) ? rates : rates ? [rates] : []),
|
||||||
error: () => (this.cbsBaseRates = [])
|
error: () => (this.cbsBaseRates = [])
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -127,7 +127,8 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
get activeBase(): any {
|
get activeBase(): any {
|
||||||
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
|
// The recurring deposit's own CBS base chart; its active version carries the senior citizen rate of each slab.
|
||||||
|
return (this.cbsBaseRates || []).find((r) => r.isActive) ?? (this.cbsBaseRates || [])[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,7 +174,10 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
|||||||
return baseSlabs;
|
return baseSlabs;
|
||||||
}
|
}
|
||||||
// Products made before RD charts moved to months still hold the base chart's own year slabs
|
// Products made before RD charts moved to months still hold the base chart's own year slabs
|
||||||
const candidates = [...baseSlabs, ...this.normalizeCbsSlabs(cbsSlabs)];
|
const candidates = [
|
||||||
|
...baseSlabs,
|
||||||
|
...this.normalizeCbsSlabs(cbsSlabs)
|
||||||
|
];
|
||||||
return productSlabs.map((slab: any) => {
|
return productSlabs.map((slab: any) => {
|
||||||
if (slab.seniorCitizenRate != null) {
|
if (slab.seniorCitizenRate != null) {
|
||||||
return slab;
|
return slab;
|
||||||
|
|||||||
+28
@@ -50,6 +50,34 @@
|
|||||||
<span class="nx-row__label">{{ 'labels.inputs.Maturity Date' | translate }}</span>
|
<span class="nx-row__label">{{ 'labels.inputs.Maturity Date' | translate }}</span>
|
||||||
<span class="nx-row__value">{{ recurringDepositsAccountData.maturityDate | dateFormat }}</span>
|
<span class="nx-row__value">{{ recurringDepositsAccountData.maturityDate | dateFormat }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if (installmentPosition) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">Next Installment Due</span>
|
||||||
|
<span class="nx-row__value">
|
||||||
|
@if (installmentPosition.nextDueDate) {
|
||||||
|
{{ installmentPosition.nextDueDate | dateFormat }} ·
|
||||||
|
{{ installmentPosition.installmentAmount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||||
|
} @else {
|
||||||
|
<span class="nx-muted">All installments paid</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">Installments Paid</span>
|
||||||
|
<span class="nx-row__value"
|
||||||
|
>{{ installmentPosition.paidInstallments }} of {{ installmentPosition.totalInstallments }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
@if (installmentPosition.overdueInstallments > 0) {
|
||||||
|
<div class="nx-row">
|
||||||
|
<span class="nx-row__label">Overdue</span>
|
||||||
|
<span class="nx-row__value" style="color: var(--mat-sys-error, #d32f2f)">
|
||||||
|
{{ installmentPosition.overdueInstallments }} installment(s) ·
|
||||||
|
{{ installmentPosition.overdueAmount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@if (
|
@if (
|
||||||
recurringDepositsAccountData.timeline.closedOnDate || recurringDepositsAccountData.summary.totalWitddrawals
|
recurringDepositsAccountData.timeline.closedOnDate || recurringDepositsAccountData.summary.totalWitddrawals
|
||||||
) {
|
) {
|
||||||
|
|||||||
+31
@@ -46,11 +46,21 @@ export class GeneralTabComponent {
|
|||||||
totalLienAmount = 0;
|
totalLienAmount = 0;
|
||||||
/** Loan limit today (Lien Marked products only): % that applies, what it comes to, and what is left. */
|
/** Loan limit today (Lien Marked products only): % that applies, what it comes to, and what is left. */
|
||||||
lienLimit: { percentage: number; maxLoanAmount: number; availableForLoan: number } | null = null;
|
lienLimit: { percentage: number; maxLoanAmount: number; availableForLoan: number } | null = null;
|
||||||
|
/** When the next installment is expected and how much, plus anything already overdue; null once fully paid. */
|
||||||
|
installmentPosition: {
|
||||||
|
nextDueDate: string | null;
|
||||||
|
installmentAmount: number;
|
||||||
|
paidInstallments: number;
|
||||||
|
totalInstallments: number;
|
||||||
|
overdueInstallments: number;
|
||||||
|
overdueAmount: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => {
|
this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => {
|
||||||
this.recurringDepositsAccountData = data.recurringDepositsAccountData;
|
this.recurringDepositsAccountData = data.recurringDepositsAccountData;
|
||||||
this.loadLiens(this.recurringDepositsAccountData?.id);
|
this.loadLiens(this.recurringDepositsAccountData?.id);
|
||||||
|
this.loadInstallmentPosition(this.recurringDepositsAccountData?.id);
|
||||||
this.currency = this.recurringDepositsAccountData.currency;
|
this.currency = this.recurringDepositsAccountData.currency;
|
||||||
this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null;
|
this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null;
|
||||||
if (this.router.url.includes('clients')) {
|
if (this.router.url.includes('clients')) {
|
||||||
@@ -63,6 +73,27 @@ export class GeneralTabComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadInstallmentPosition(accountId: any) {
|
||||||
|
this.installmentPosition = null;
|
||||||
|
if (!accountId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.recurringDepositsService.getRecurringDepositInstallmentPosition(accountId).subscribe((response: any) => {
|
||||||
|
const row = (response?.accounts ?? []).find((a: any) => Number(a.id) === Number(accountId));
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.installmentPosition = {
|
||||||
|
nextDueDate: row.nextDueDate || null,
|
||||||
|
installmentAmount: Number(row.installmentAmount) || 0,
|
||||||
|
paidInstallments: Number(row.paidInstallments) || 0,
|
||||||
|
totalInstallments: Number(row.totalInstallments) || 0,
|
||||||
|
overdueInstallments: Number(row.overdueInstallments) || 0,
|
||||||
|
overdueAmount: Number(row.overdueAmount) || 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private loadLiens(accountId: any) {
|
private loadLiens(accountId: any) {
|
||||||
this.liens = [];
|
this.liens = [];
|
||||||
this.totalLienAmount = 0;
|
this.totalLienAmount = 0;
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ export class RecurringDepositsService {
|
|||||||
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`);
|
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This deposit's installment position as of the business date (next due date, overdue count/amount), from the
|
||||||
|
* RD Dashboard endpoint - the same paid/overdue rule the auto-debit and penalty jobs use. Older backends ignore
|
||||||
|
* `accountId` and return every RD, so callers pick the row by id.
|
||||||
|
*/
|
||||||
|
getRecurringDepositInstallmentPosition(recurringDepositAccountId: any): Observable<any> {
|
||||||
|
const httpParams = new HttpParams().set('accountId', recurringDepositAccountId);
|
||||||
|
return this.http.get('/recurringdepositaccounts/dashboard', { params: httpParams });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param clientId Client Id
|
* @param clientId Client Id
|
||||||
* @param clientName Client Name
|
* @param clientName Client Name
|
||||||
|
|||||||
+12
-6
@@ -719,7 +719,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
[type='number'] {
|
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||||
|
input[type='number'] {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,7 +760,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* :where() keeps these defaults at zero specificity, so the table's own cell inputs always win. */
|
/* :where() keeps these defaults at zero specificity, so the table's own cell inputs always win. */
|
||||||
:where([type='text'], [type='number'], [type='date'], select, textarea) {
|
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||||
|
:where(input[type='text'], input[type='number'], input[type='date'], select, textarea) {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
@@ -771,11 +773,13 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
:where([type='text'], select) {
|
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||||
|
:where(input[type='text'], select) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:where([type='checkbox']) {
|
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||||
|
:where(input[type='checkbox']) {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,7 +869,8 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
[type='number'] {
|
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||||
|
input[type='number'] {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1175,7 +1180,8 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
|
|
||||||
[type='text'] {
|
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||||
|
input[type='text'] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 9px 11px;
|
padding: 9px 11px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ export interface ReserveTransferData {
|
|||||||
export class ReserveTransferDialogComponent {
|
export class ReserveTransferDialogComponent {
|
||||||
private dialogRef = inject(MatDialogRef<ReserveTransferDialogComponent>);
|
private dialogRef = inject(MatDialogRef<ReserveTransferDialogComponent>);
|
||||||
private rbi = inject(RbiCircularsService);
|
private rbi = inject(RbiCircularsService);
|
||||||
readonly data: ReserveTransferData = inject(MAT_DIALOG_DATA);
|
|
||||||
|
|
||||||
/** The account money leaves. Fixed — the dialog is opened from its row. */
|
/** The account money leaves. Fixed — the dialog is opened from its row. */
|
||||||
readonly from: TransferAccount;
|
readonly from: TransferAccount;
|
||||||
@@ -76,6 +75,8 @@ export class ReserveTransferDialogComponent {
|
|||||||
submitting = false;
|
submitting = false;
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
|
readonly data = inject<ReserveTransferData>(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.from = this.data.from;
|
this.from = this.data.from;
|
||||||
this.destinations = (this.data.accounts ?? []).filter((a) => a.glAccountId !== this.data.from.glAccountId);
|
this.destinations = (this.data.accounts ?? []).filter((a) => a.glAccountId !== this.data.from.glAccountId);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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 {}
|
|
||||||
@@ -1,154 +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 } from '@angular/core';
|
|
||||||
import { Observable, delay, of, throwError } from 'rxjs';
|
|
||||||
import { demoBorrower, demoBorrowers, demoSummary } from './loan-eligibility.mock';
|
|
||||||
|
|
||||||
export type BorrowerCategory = 'GOOD' | 'WATCH' | 'BAD';
|
|
||||||
|
|
||||||
export interface LoanStageRow {
|
|
||||||
stage: string;
|
|
||||||
label: string;
|
|
||||||
dpdRange: string;
|
|
||||||
group: 'PERFORMING' | 'NPA' | 'CLOSED';
|
|
||||||
loans: number;
|
|
||||||
outstanding: number;
|
|
||||||
overdue: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoanProductRow {
|
|
||||||
productId: number;
|
|
||||||
productName: string;
|
|
||||||
activeLoans: number;
|
|
||||||
performingLoans: number;
|
|
||||||
npaLoans: number;
|
|
||||||
outstanding: number;
|
|
||||||
npaOutstanding: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loan book health as on the business date (RBI asset classes, products, borrowers). */
|
|
||||||
export interface LoanEligibilitySummary {
|
|
||||||
asOnDate: string;
|
|
||||||
activeLoans: number;
|
|
||||||
activeOutstanding: number;
|
|
||||||
performingLoans: number;
|
|
||||||
performingOutstanding: number;
|
|
||||||
npaLoans: number;
|
|
||||||
npaOutstanding: number;
|
|
||||||
grossNpaPct: number;
|
|
||||||
closedLoans: number;
|
|
||||||
writtenOffLoans: number;
|
|
||||||
writtenOffAmount: number;
|
|
||||||
/** Share linking to borrowing across members with running loans. */
|
|
||||||
shareRequired: number;
|
|
||||||
shareHeld: number;
|
|
||||||
shareShortMembers: number;
|
|
||||||
shareShortfall: number;
|
|
||||||
goodBorrowers: number;
|
|
||||||
watchBorrowers: number;
|
|
||||||
badBorrowers: number;
|
|
||||||
stages: LoanStageRow[];
|
|
||||||
products: LoanProductRow[];
|
|
||||||
notes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BorrowerLoan {
|
|
||||||
loanId: number;
|
|
||||||
accountNo: string;
|
|
||||||
productName: string;
|
|
||||||
status: string;
|
|
||||||
stage: string;
|
|
||||||
dpd: number;
|
|
||||||
emisPaid: number;
|
|
||||||
emisLate: number;
|
|
||||||
disbursed: number;
|
|
||||||
outstanding: number;
|
|
||||||
overdue: number;
|
|
||||||
sharePct: number;
|
|
||||||
shareRequired: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AccountMonth {
|
|
||||||
month: string;
|
|
||||||
credits: number;
|
|
||||||
debits: number;
|
|
||||||
avgBalance: number;
|
|
||||||
returns: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Savings / current account statement, month by month. */
|
|
||||||
export interface BorrowerAccount {
|
|
||||||
type: 'Savings' | 'Current';
|
|
||||||
accountNo: string;
|
|
||||||
months: AccountMonth[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProductEligibility {
|
|
||||||
productId: number;
|
|
||||||
productName: string;
|
|
||||||
eligible: boolean;
|
|
||||||
eligibleAmount?: number;
|
|
||||||
sharePct: number;
|
|
||||||
/** Shares to be taken with this loan (incl. any existing shortfall). */
|
|
||||||
shareToBuy?: number;
|
|
||||||
netInHand?: number;
|
|
||||||
minAmount?: number;
|
|
||||||
maxAmount?: number;
|
|
||||||
passed: string[];
|
|
||||||
failed: string[];
|
|
||||||
notes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Borrower {
|
|
||||||
clientId: number;
|
|
||||||
clientName: string;
|
|
||||||
clientAccountNo: string;
|
|
||||||
category: BorrowerCategory;
|
|
||||||
activeLoans: number;
|
|
||||||
closedLoans: number;
|
|
||||||
writtenOffLoans: number;
|
|
||||||
outstanding: number;
|
|
||||||
maxDpd: number;
|
|
||||||
worstStage?: string;
|
|
||||||
monthlyIncome?: number;
|
|
||||||
age?: number;
|
|
||||||
depositBalance: number;
|
|
||||||
currentEmi: number;
|
|
||||||
eligibleProducts: number;
|
|
||||||
/** Repayment record over all loans: EMIs fallen due and how many were paid late. */
|
|
||||||
emisPaid: number;
|
|
||||||
emisLate: number;
|
|
||||||
shareHeld: number;
|
|
||||||
shareRequired: number;
|
|
||||||
/** Six-month averages of the operative account. */
|
|
||||||
accountType: 'Savings' | 'Current';
|
|
||||||
avgCredits: number;
|
|
||||||
avgDebits: number;
|
|
||||||
returns: number;
|
|
||||||
accounts?: BorrowerAccount[];
|
|
||||||
loans?: BorrowerLoan[];
|
|
||||||
eligibility?: ProductEligibility[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loan Eligibility (AI / Intelligence – Lending & Risk): demo data, worked out on the eligibility rules. */
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class LoanEligibilityService {
|
|
||||||
getSummary(): Observable<LoanEligibilitySummary> {
|
|
||||||
return of(demoSummary()).pipe(delay(2800));
|
|
||||||
}
|
|
||||||
|
|
||||||
getBorrowers(category: BorrowerCategory | '', search: string): Observable<Borrower[]> {
|
|
||||||
return of(demoBorrowers(category, search)).pipe(delay(700));
|
|
||||||
}
|
|
||||||
|
|
||||||
getBorrower(clientId: number): Observable<Borrower> {
|
|
||||||
const b = demoBorrower(clientId);
|
|
||||||
return b ? of(b).pipe(delay(900)) : throwError(() => new Error('Borrower not found'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -967,7 +967,6 @@ export const REPORT_META: ReportMeta[] = [
|
|||||||
{ id: 'probable', group: 'classification', label: 'Probable NPA' },
|
{ id: 'probable', group: 'classification', label: 'Probable NPA' },
|
||||||
{ id: 'sma', group: 'classification', label: 'SMA Report' },
|
{ id: 'sma', group: 'classification', label: 'SMA Report' },
|
||||||
{ id: 'recovery-dashboard', group: 'recovery-mgmt', label: 'Recovery Management Dashboard' },
|
{ id: 'recovery-dashboard', group: 'recovery-mgmt', label: 'Recovery Management Dashboard' },
|
||||||
{ id: 'cc-recovery', group: 'recovery-mgmt', label: 'Cash Credit (CC) Recovery Management' },
|
|
||||||
{ id: 'daywise-overdue', group: 'recovery-mgmt', label: 'Day-wise Overdue Report' },
|
{ id: 'daywise-overdue', group: 'recovery-mgmt', label: 'Day-wise Overdue Report' },
|
||||||
{ id: 'daywise-overdrawn', group: 'recovery-mgmt', label: 'Day-wise Overdrawn Report' },
|
{ id: 'daywise-overdrawn', group: 'recovery-mgmt', label: 'Day-wise Overdrawn Report' },
|
||||||
{ id: 'agri-gold-value', group: 'recovery-mgmt', label: 'Agriculture / Gold Market Value Report' },
|
{ id: 'agri-gold-value', group: 'recovery-mgmt', label: 'Agriculture / Gold Market Value Report' },
|
||||||
|
|||||||
@@ -1400,184 +1400,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- ===================== CASH CREDIT (CC) RECOVERY MANAGEMENT ===================== -->
|
|
||||||
@if (activeReport === 'cc-recovery') {
|
|
||||||
<div class="pg-card">
|
|
||||||
<div class="pos-section-label">
|
|
||||||
Cash Credit (CC) Recovery Management — Drawing Power & Overdrawn-Day Tracking
|
|
||||||
</div>
|
|
||||||
<p class="pg-sub npa-report-note">
|
|
||||||
Cash Credit is a revolving working-capital facility secured against stock / receivables. Unlike a Term Loan, it
|
|
||||||
isn't checked against a fixed instalment — it is checked against its <strong>Drawing Power (DP)</strong>, and
|
|
||||||
tracked by how many continuous <strong>Days Overdrawn</strong> ("Out of Order") it has run — crossing 90 days
|
|
||||||
turns it <strong>NPA</strong>.
|
|
||||||
</p>
|
|
||||||
<div class="npa-glossary">
|
|
||||||
<div class="npa-glossary-card npa-glossary-card--dp">
|
|
||||||
<span class="npa-glossary-card__icon"><fa-icon icon="wallet"></fa-icon></span>
|
|
||||||
<div class="npa-glossary-card__term">Drawing Power (DP)</div>
|
|
||||||
<p class="npa-glossary-card__desc">
|
|
||||||
The maximum amount the borrower may actually draw against the sanctioned CC limit — fixed from the value of
|
|
||||||
stock / book-debts declared in the latest stock statement. Can be lower than the sanctioned limit.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="npa-glossary-card npa-glossary-card--ooo">
|
|
||||||
<span class="npa-glossary-card__icon"><fa-icon icon="hourglass-half"></fa-icon></span>
|
|
||||||
<div class="npa-glossary-card__term">Out of Order</div>
|
|
||||||
<p class="npa-glossary-card__desc">
|
|
||||||
A CC account turns "Out of Order" once its outstanding continuously stays above the limit/DP, or adequate
|
|
||||||
credits stop coming in, for 90 days — the CC-specific overdue trigger, in place of a missed EMI.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="npa-glossary-card npa-glossary-card--sma">
|
|
||||||
<span class="npa-glossary-card__icon"><fa-icon icon="flag"></fa-icon></span>
|
|
||||||
<div class="npa-glossary-card__term">Days Overdrawn</div>
|
|
||||||
<p class="npa-glossary-card__desc">
|
|
||||||
The running count of continuous days an account has stayed Out of Order — the number a recovery desk
|
|
||||||
actually watches day to day, instead of a classification label. A rising count is the early-warning sign.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="npa-glossary-card npa-glossary-card--npa">
|
|
||||||
<span class="npa-glossary-card__icon"><fa-icon icon="ban"></fa-icon></span>
|
|
||||||
<div class="npa-glossary-card__term">NPA (Non-Performing Asset)</div>
|
|
||||||
<p class="npa-glossary-card__desc">
|
|
||||||
Once Out of Order crosses 90 days, the CC account slips to NPA — Sub-Standard, then Doubtful-1/2/3, then
|
|
||||||
Loss Asset as it ages further.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pg-card">
|
|
||||||
<div class="inv-kpis npa-kpis--ratio npa-kpis--compact">
|
|
||||||
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--neutral">
|
|
||||||
<span class="npa-kpi__icon"><fa-icon icon="wallet"></fa-icon></span>
|
|
||||||
<span class="inv-kpi__label">Total CC Accounts</span>
|
|
||||||
<span class="inv-kpi__value">{{ ccAccounts.length }}</span>
|
|
||||||
<span class="inv-kpi__sub">₹{{ ccTotalSanctioned | number: '1.0-0' }} sanctioned</span>
|
|
||||||
</div>
|
|
||||||
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--warn">
|
|
||||||
<span class="npa-kpi__icon"><fa-icon icon="hourglass-half"></fa-icon></span>
|
|
||||||
<span class="inv-kpi__label">Overdrawn (1–90 Days)</span>
|
|
||||||
<span class="inv-kpi__value" [class.npa-warn]="ccOverdrawnCount > 0">{{ ccOverdrawnCount }}</span>
|
|
||||||
<span class="inv-kpi__sub">Out of Order, not yet NPA</span>
|
|
||||||
</div>
|
|
||||||
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--npa">
|
|
||||||
<span class="npa-kpi__icon"><fa-icon icon="ban"></fa-icon></span>
|
|
||||||
<span class="inv-kpi__label">NPA (90+ Days Overdrawn)</span>
|
|
||||||
<span class="inv-kpi__value npa-warn">{{ ccNpaCount }}</span>
|
|
||||||
<span class="inv-kpi__sub">₹{{ ccTotalOutstanding | number: '1.0-0' }} total outstanding</span>
|
|
||||||
</div>
|
|
||||||
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--info">
|
|
||||||
<span class="npa-kpi__icon"><fa-icon icon="money-bill-wave"></fa-icon></span>
|
|
||||||
<span class="inv-kpi__label">Interest Paid (This Cycle)</span>
|
|
||||||
<span class="inv-kpi__value">₹{{ ccInterestPaidThisCycle | number: '1.0-0' }}</span>
|
|
||||||
<span class="inv-kpi__sub">of ₹{{ ccInterestDueThisCycle | number: '1.0-0' }} due</span>
|
|
||||||
</div>
|
|
||||||
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--positive">
|
|
||||||
<span class="npa-kpi__icon"><fa-icon icon="hand-holding-usd"></fa-icon></span>
|
|
||||||
<span class="inv-kpi__label">Total Recovered</span>
|
|
||||||
<span class="inv-kpi__value">₹{{ ccTotalRecovered | number: '1.0-0' }}</span>
|
|
||||||
<span class="inv-kpi__sub"
|
|
||||||
>{{ ccRecoveryPct | number: '1.1-1' }}% of ₹{{ ccTotalSanctioned | number: '1.0-0' }} sanctioned</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pg-card">
|
|
||||||
<div class="pos-section-label">
|
|
||||||
Cash Credit Accounts — Limit, DP & Classification ({{ ccOutOfOrderCount }} of {{ ccAccounts.length }} Out of
|
|
||||||
Order)
|
|
||||||
</div>
|
|
||||||
<div class="pg-table-scroll">
|
|
||||||
<table class="pg-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Account No</th>
|
|
||||||
<th>Customer</th>
|
|
||||||
<th>Branch</th>
|
|
||||||
<th class="num">Sanctioned Limit</th>
|
|
||||||
<th class="num">Drawing Power (DP)</th>
|
|
||||||
<th class="num">Outstanding</th>
|
|
||||||
<th class="num col-out">Pending to Recover</th>
|
|
||||||
<th class="num col-out">Excess Over DP</th>
|
|
||||||
<th class="num">Interest Due (This Cycle)</th>
|
|
||||||
<th class="num col-in">Interest Paid (This Cycle)</th>
|
|
||||||
<th class="num col-in">Total Recovered</th>
|
|
||||||
<th class="num">Days Overdrawn</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Recovery Officer</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (a of paged(ccAccounts); track a.accountNo) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span class="pg-chip">{{ a.accountNo }}</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ a.customerName }}</td>
|
|
||||||
<td>{{ a.branchName }}</td>
|
|
||||||
<td class="num">₹{{ a.sanctionedAmount | number: '1.0-0' }}</td>
|
|
||||||
<td class="num">₹{{ a.drawingPower | number: '1.0-0' }}</td>
|
|
||||||
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
|
|
||||||
<td class="num col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
|
|
||||||
<td class="num col-out">₹{{ excessOverDp(a) | number: '1.0-0' }}</td>
|
|
||||||
<td class="num">₹{{ expectedThisMonth(a) | number: '1.0-0' }}</td>
|
|
||||||
<td class="num" [class.col-in]="actualThisMonth(a) > 0" [class.col-out]="actualThisMonth(a) === 0">
|
|
||||||
₹{{ actualThisMonth(a) | number: '1.0-0' }}
|
|
||||||
</td>
|
|
||||||
<td class="num col-in">₹{{ recovered(a) | number: '1.0-0' }}</td>
|
|
||||||
<td class="num">{{ isNpaStageOf(a) || overdrawnBreachOf(a) ? a.overdueDays : 0 }}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="npa-badge"
|
|
||||||
[class.npa-badge--standard]="!isNpaStageOf(a) && !overdrawnBreachOf(a)"
|
|
||||||
[class.npa-badge--sma1]="!isNpaStageOf(a) && overdrawnBreachOf(a)"
|
|
||||||
[class.npa-badge--substandard]="isNpaStageOf(a)"
|
|
||||||
>
|
|
||||||
{{ isNpaStageOf(a) ? 'NPA' : overdrawnBreachOf(a) ? 'Overdrawn' : 'Regular' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ a.recoveryOfficer }}</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="14" class="pg-empty">No Cash Credit accounts in this branch.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
@if (ccAccounts.length > pageSize) {
|
|
||||||
<div class="pg-pagination">
|
|
||||||
<span class="pg-pagination__info"
|
|
||||||
>Showing {{ rangeStart(ccAccounts) }}–{{ rangeEnd(ccAccounts) }} of {{ ccAccounts.length }}</span
|
|
||||||
>
|
|
||||||
<div class="pg-pagination__controls">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pg-btn pg-btn--ghost"
|
|
||||||
[disabled]="currentPage === 1"
|
|
||||||
(click)="goToPage(currentPage - 1, ccAccounts)"
|
|
||||||
>
|
|
||||||
‹ Prev
|
|
||||||
</button>
|
|
||||||
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(ccAccounts) }}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="pg-btn pg-btn--ghost"
|
|
||||||
[disabled]="currentPage === pageCount(ccAccounts)"
|
|
||||||
(click)="goToPage(currentPage + 1, ccAccounts)"
|
|
||||||
>
|
|
||||||
Next ›
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- ===================== 14 AGRICULTURE / GOLD MARKET VALUE REPORT ===================== -->
|
<!-- ===================== 14 AGRICULTURE / GOLD MARKET VALUE REPORT ===================== -->
|
||||||
@if (activeReport === 'agri-gold-value') {
|
@if (activeReport === 'agri-gold-value') {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
|
|||||||
@@ -660,69 +660,6 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
|||||||
return this.overdrawnAccounts.filter((a) => this.overdrawnBreachOf(a)).length;
|
return this.overdrawnAccounts.filter((a) => this.overdrawnBreachOf(a)).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Cash Credit (CC) Recovery Management --------------------
|
|
||||||
/** Pure Cash Credit (MSME) accounts only — product code '01' — kept separate from the combined
|
|
||||||
* Overdrawn (CC/OD/KCC) bucket so CC's own Drawing Power / Out-of-Order / SMA-NPA position can
|
|
||||||
* be reviewed on its own, the way a recovery desk actually works a CC book. */
|
|
||||||
get ccAccounts(): LoanAccount[] {
|
|
||||||
return this.allAccounts
|
|
||||||
.filter((a) => a.productCode === '01')
|
|
||||||
.sort((a, b) => this.excessOverDp(b) - this.excessOverDp(a));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Any CC account that's irregular — either drawn beyond its Drawing Power right now, or already
|
|
||||||
* NPA-staged from its overdrawn history (an NPA account doesn't "go Regular" just because it's
|
|
||||||
* briefly back within DP — RBI upgrade norms require full clearance first). Covers the same
|
|
||||||
* population the 1–90-day Overdrawn and 90+-day NPA buckets below split in two. */
|
|
||||||
get ccOutOfOrderCount(): number {
|
|
||||||
return this.ccAccounts.filter((a) => this.overdrawnBreachOf(a) || this.isNpaStageOf(a)).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Out of Order for 1–90 continuous days — the pre-NPA monitoring window a recovery desk tracks
|
|
||||||
* by plain day-count, not by an SMA-0/1/2 label (that split only matters for regulatory
|
|
||||||
* reporting, not the day-to-day collection screen). */
|
|
||||||
get ccOverdrawnCount(): number {
|
|
||||||
return this.ccAccounts.filter((a) => this.overdrawnBreachOf(a) && !this.isNpaStageOf(a)).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Out of Order for more than 90 continuous days — the point RBI norms classify a CC/OD account
|
|
||||||
* as NPA. */
|
|
||||||
get ccNpaCount(): number {
|
|
||||||
return this.ccAccounts.filter((a) => this.isNpaStageOf(a)).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
get ccTotalSanctioned(): number {
|
|
||||||
return this.ccAccounts.reduce((s, a) => s + a.sanctionedAmount, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get ccTotalOutstanding(): number {
|
|
||||||
return this.ccAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A Cash Credit account has no fixed EMI — each cycle's "due" amount is interest servicing on
|
|
||||||
* the drawn balance, so this total doubles as "how much interest was CC customers due to pay
|
|
||||||
* this cycle". */
|
|
||||||
get ccInterestDueThisCycle(): number {
|
|
||||||
return this.ccAccounts.reduce((s, a) => s + this.expectedThisMonth(a), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How much of that interest the CC book has actually paid this cycle — the answer to "which
|
|
||||||
* customers have paid how much interest", summed across the Cash Credit book. */
|
|
||||||
get ccInterestPaidThisCycle(): number {
|
|
||||||
return this.ccAccounts.reduce((s, a) => s + this.actualThisMonth(a), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Lifetime cash recovered against the CC book (recorded recovery transactions — cash deposits,
|
|
||||||
* cheques, NEFT/RTGS, auction/SARFAESI, OTS) — the answer to "loan diya kitna, recover kitna
|
|
||||||
* hua", measured against what was actually sanctioned. */
|
|
||||||
get ccTotalRecovered(): number {
|
|
||||||
return this.ccAccounts.reduce((s, a) => s + this.recovered(a), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get ccRecoveryPct(): number {
|
|
||||||
return this.ccTotalSanctioned ? (this.ccTotalRecovered / this.ccTotalSanctioned) * 100 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------- Agriculture / Gold Market Value Report --------------------
|
// -------------------- Agriculture / Gold Market Value Report --------------------
|
||||||
get agriGoldAccounts(): LoanAccount[] {
|
get agriGoldAccounts(): LoanAccount[] {
|
||||||
return this.allAccounts
|
return this.allAccounts
|
||||||
@@ -1680,63 +1617,6 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
case 'cc-recovery':
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
sheetName: 'Cash Credit Recovery',
|
|
||||||
title: 'Cash Credit (CC) Recovery Management — Drawing Power & Overdrawn-Day Tracking',
|
|
||||||
subtitle,
|
|
||||||
headers: [
|
|
||||||
'Account No',
|
|
||||||
'Customer',
|
|
||||||
'Branch',
|
|
||||||
'Sanctioned Limit',
|
|
||||||
'Drawing Power',
|
|
||||||
'Outstanding',
|
|
||||||
'Pending to Recover',
|
|
||||||
'Excess Over DP',
|
|
||||||
'Interest Due (This Cycle)',
|
|
||||||
'Interest Paid (This Cycle)',
|
|
||||||
'Total Recovered',
|
|
||||||
'Days Overdrawn',
|
|
||||||
'Status',
|
|
||||||
'Recovery Officer'
|
|
||||||
],
|
|
||||||
widths: [
|
|
||||||
16,
|
|
||||||
22,
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
16,
|
|
||||||
18,
|
|
||||||
18,
|
|
||||||
18,
|
|
||||||
14,
|
|
||||||
14,
|
|
||||||
20
|
|
||||||
],
|
|
||||||
rows: this.ccAccounts.map((a) => [
|
|
||||||
a.accountNo,
|
|
||||||
a.customerName,
|
|
||||||
a.branchName,
|
|
||||||
a.sanctionedAmount,
|
|
||||||
a.drawingPower,
|
|
||||||
a.outstandingBalance,
|
|
||||||
a.overdueAmount,
|
|
||||||
this.excessOverDp(a),
|
|
||||||
this.expectedThisMonth(a),
|
|
||||||
this.actualThisMonth(a),
|
|
||||||
this.recovered(a),
|
|
||||||
this.isNpaStageOf(a) || this.overdrawnBreachOf(a) ? a.overdueDays : 0,
|
|
||||||
this.isNpaStageOf(a) ? 'NPA' : this.overdrawnBreachOf(a) ? 'Overdrawn' : 'Regular',
|
|
||||||
a.recoveryOfficer
|
|
||||||
])
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
case 'daywise-overdrawn':
|
case 'daywise-overdrawn':
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,165 +94,6 @@
|
|||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Cash Credit Recovery Management — a glossary strip explaining DP / Out of
|
|
||||||
// Order / SMA / NPA to a reader meeting the terms for the first time (e.g. a
|
|
||||||
// stakeholder demo), plus tone-coloured accents for that screen's KPI row.
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
.npa-glossary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.npa-glossary-card {
|
|
||||||
padding: 16px 16px 16px 18px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-left: 4px solid #64748b;
|
|
||||||
transition: box-shadow 0.15s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #475569;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__term {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: #0f172a;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__desc {
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: #475569;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--dp {
|
|
||||||
border-left-color: #2563eb;
|
|
||||||
|
|
||||||
.npa-glossary-card__icon {
|
|
||||||
background: #dbeafe;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--ooo {
|
|
||||||
border-left-color: #d97706;
|
|
||||||
|
|
||||||
.npa-glossary-card__icon {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #b45309;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--sma {
|
|
||||||
border-left-color: #ca8a04;
|
|
||||||
|
|
||||||
.npa-glossary-card__icon {
|
|
||||||
background: #fef9c3;
|
|
||||||
color: #854d0e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--npa {
|
|
||||||
border-left-color: #dc2626;
|
|
||||||
|
|
||||||
.npa-glossary-card__icon {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compact gap variant for a KPI row that otherwise uses npa-kpis--ratio's 4-wide grid — used by
|
|
||||||
// the CC Recovery Management summary row, which packs more (smaller) tiles than the main header.
|
|
||||||
.npa-kpis--compact {
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tone-coloured KPI tile for the CC summary row — same .inv-kpi tile, with a left accent + icon
|
|
||||||
// chip so "Out of Order"/"SMA"/"NPA" read as a severity strip rather than four identical boxes.
|
|
||||||
// Sized smaller than the default .inv-kpi tile since this row packs 6 of them.
|
|
||||||
.npa-kpi--accent {
|
|
||||||
padding: 10px 12px 10px 14px;
|
|
||||||
border-left: 4px solid var(--npa-kpi-accent, #64748b);
|
|
||||||
|
|
||||||
.inv-kpi__label {
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-kpi__value {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inv-kpi__sub {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.npa-kpi__icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--npa-kpi-accent-soft, #f1f5f9);
|
|
||||||
color: var(--npa-kpi-accent, #475569);
|
|
||||||
font-size: 10px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--neutral {
|
|
||||||
--npa-kpi-accent: #0f4f3c;
|
|
||||||
--npa-kpi-accent-soft: #dcfce7;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--warn {
|
|
||||||
--npa-kpi-accent: #d97706;
|
|
||||||
--npa-kpi-accent-soft: #fef3c7;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--sma {
|
|
||||||
--npa-kpi-accent: #ca8a04;
|
|
||||||
--npa-kpi-accent-soft: #fef9c3;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--npa {
|
|
||||||
--npa-kpi-accent: #dc2626;
|
|
||||||
--npa-kpi-accent-soft: #fee2e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--info {
|
|
||||||
--npa-kpi-accent: #2563eb;
|
|
||||||
--npa-kpi-accent-soft: #dbeafe;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--positive {
|
|
||||||
--npa-kpi-accent: #15803d;
|
|
||||||
--npa-kpi-accent-soft: #dcfce7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account Detail header — title/badge on the left, the Recovery Journey trigger pinned right.
|
// Account Detail header — title/badge on the left, the Recovery Journey trigger pinned right.
|
||||||
.npa-detail-header {
|
.npa-detail-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export class PlatformMenuAccessComponent {
|
|||||||
{ code: 'MENU_DEPOSITS', label: 'Deposits', icon: 'piggy-bank' },
|
{ code: 'MENU_DEPOSITS', label: 'Deposits', icon: 'piggy-bank' },
|
||||||
{ code: 'MENU_ADVANCES', label: 'Advances', icon: 'hand-holding-usd' },
|
{ code: 'MENU_ADVANCES', label: 'Advances', icon: 'hand-holding-usd' },
|
||||||
{ code: 'MENU_LOAN_ORIGINATION', label: 'Loan Origination', icon: 'clipboard-check' },
|
{ code: 'MENU_LOAN_ORIGINATION', label: 'Loan Origination', icon: 'clipboard-check' },
|
||||||
{ code: 'MENU_CREDIT_BUREAU', label: 'Credit Bureau', icon: 'id-card' },
|
|
||||||
{ code: 'MENU_SHARE', label: 'Share', icon: 'coins' },
|
{ code: 'MENU_SHARE', label: 'Share', icon: 'coins' },
|
||||||
{ code: 'MENU_NPA', label: 'NPA', icon: 'exclamation-triangle' },
|
{ code: 'MENU_NPA', label: 'NPA', icon: 'exclamation-triangle' },
|
||||||
{ code: 'MENU_GENERAL_LEDGER', label: 'General Ledger', icon: 'book-open' },
|
{ code: 'MENU_GENERAL_LEDGER', label: 'General Ledger', icon: 'book-open' },
|
||||||
|
|||||||
+18
-8
@@ -11,16 +11,18 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2>CBS Base Interest Rate</h2>
|
<h2>CBS Base Interest Rate</h2>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
Manage master CBS interest rate slabs by deposit type. Saving a new version automatically
|
Manage master CBS interest rate slabs by deposit type. SINGLE, DOUBLE and SPECIAL are the fixed deposit charts;
|
||||||
<strong>deactivates</strong> the current active version and <strong>activates</strong> the new one. This chart
|
the
|
||||||
is used as the reference for both Fixed Deposit and Recurring Deposit products.
|
<strong>RD</strong> tab is the recurring deposit chart, with its own senior citizen rates, used when an RD
|
||||||
|
product is created and when an RD account is opened. Saving a new version automatically
|
||||||
|
<strong>deactivates</strong> the current active version and <strong>activates</strong> the new one.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<mat-tab-group animationDuration="150ms">
|
<mat-tab-group animationDuration="150ms">
|
||||||
@for (fdType of fdTypes; track fdType) {
|
@for (fdType of fdTypes; track fdType) {
|
||||||
<mat-tab [label]="fdType">
|
<mat-tab [label]="tabLabel(fdType)">
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@if (!showPreview[fdType]) {
|
@if (!showPreview[fdType]) {
|
||||||
<!-- ── Editable Slabs Table ── -->
|
<!-- ── Editable Slabs Table ── -->
|
||||||
@@ -31,6 +33,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (fdType === 'RD' && rdChartInDays) {
|
||||||
|
<p class="empty-text">
|
||||||
|
The recurring deposit chart in use is still in days. Recurring deposits are priced by months or years,
|
||||||
|
so enter the slabs below in Months or Years (for example 1 to 3 Months, 4 to 5 Months, 6 to 11 Months,
|
||||||
|
12 to 35 Months) and save.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
@if (slabs(fdType).length === 0) {
|
@if (slabs(fdType).length === 0) {
|
||||||
<p class="empty-text">No slabs defined. Click "Add Slab" to begin.</p>
|
<p class="empty-text">No slabs defined. Click "Add Slab" to begin.</p>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -55,7 +65,7 @@
|
|||||||
<td mat-cell *matCellDef="let row">
|
<td mat-cell *matCellDef="let row">
|
||||||
<mat-form-field class="cell-field cell-field--sm">
|
<mat-form-field class="cell-field cell-field--sm">
|
||||||
<mat-select [formControl]="row.get('periodTypeEnum')">
|
<mat-select [formControl]="row.get('periodTypeEnum')">
|
||||||
@for (pt of periodTypes; track pt.id) {
|
@for (pt of periodTypesFor(fdType); track pt.id) {
|
||||||
<mat-option [value]="pt.id">{{ pt.label }}</mat-option>
|
<mat-option [value]="pt.id">{{ pt.label }}</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
@@ -198,7 +208,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="periodType">
|
<ng-container matColumnDef="periodType">
|
||||||
<th mat-header-cell *matHeaderCellDef>Period</th>
|
<th mat-header-cell *matHeaderCellDef>Period</th>
|
||||||
<td mat-cell *matCellDef="let s">{{ getPeriodLabel(s.periodTypeEnum) }}</td>
|
<td mat-cell *matCellDef="let s">{{ getPeriodLabel(s.periodTypeEnum, fdType) }}</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="fromPeriod">
|
<ng-container matColumnDef="fromPeriod">
|
||||||
<th mat-header-cell *matHeaderCellDef>From</th>
|
<th mat-header-cell *matHeaderCellDef>From</th>
|
||||||
@@ -245,7 +255,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="periodType">
|
<ng-container matColumnDef="periodType">
|
||||||
<th mat-header-cell *matHeaderCellDef>Period</th>
|
<th mat-header-cell *matHeaderCellDef>Period</th>
|
||||||
<td mat-cell *matCellDef="let s">{{ getPeriodLabel(s.periodTypeEnum) }}</td>
|
<td mat-cell *matCellDef="let s">{{ getPeriodLabel(s.periodTypeEnum, fdType) }}</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="fromPeriod">
|
<ng-container matColumnDef="fromPeriod">
|
||||||
<th mat-header-cell *matHeaderCellDef>From</th>
|
<th mat-header-cell *matHeaderCellDef>From</th>
|
||||||
@@ -335,7 +345,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="periodType">
|
<ng-container matColumnDef="periodType">
|
||||||
<th mat-header-cell *matHeaderCellDef>Period</th>
|
<th mat-header-cell *matHeaderCellDef>Period</th>
|
||||||
<td mat-cell *matCellDef="let s">{{ getPeriodLabel(s.periodTypeEnum) }}</td>
|
<td mat-cell *matCellDef="let s">{{ getPeriodLabel(s.periodTypeEnum, fdType) }}</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="fromPeriod">
|
<ng-container matColumnDef="fromPeriod">
|
||||||
<th mat-header-cell *matHeaderCellDef>From</th>
|
<th mat-header-cell *matHeaderCellDef>From</th>
|
||||||
|
|||||||
+72
-9
@@ -58,10 +58,12 @@ export class BaseFdInterestRateChartComponent implements OnInit {
|
|||||||
private productsService = inject(ProductsService);
|
private productsService = inject(ProductsService);
|
||||||
private alertService = inject(AlertService);
|
private alertService = inject(AlertService);
|
||||||
|
|
||||||
|
/** SINGLE, DOUBLE and SPECIAL are the fixed deposit charts; RD is the recurring deposit's own chart. */
|
||||||
readonly fdTypes = [
|
readonly fdTypes = [
|
||||||
'SINGLE',
|
'SINGLE',
|
||||||
'DOUBLE',
|
'DOUBLE',
|
||||||
'SPECIAL'
|
'SPECIAL',
|
||||||
|
'RD'
|
||||||
];
|
];
|
||||||
readonly periodTypes = [
|
readonly periodTypes = [
|
||||||
{ id: 0, label: 'Days' },
|
{ id: 0, label: 'Days' },
|
||||||
@@ -69,6 +71,14 @@ export class BaseFdInterestRateChartComponent implements OnInit {
|
|||||||
{ id: 2, label: 'Months' },
|
{ id: 2, label: 'Months' },
|
||||||
{ id: 3, label: 'Years' }
|
{ id: 3, label: 'Years' }
|
||||||
];
|
];
|
||||||
|
/**
|
||||||
|
* Recurring deposits keep tenure in months or years, and the server numbers the units its own way (1 days, 2 months,
|
||||||
|
* 3 years), so the RD tab offers only those two.
|
||||||
|
*/
|
||||||
|
readonly rdPeriodTypes = [
|
||||||
|
{ id: 2, label: 'Months' },
|
||||||
|
{ id: 3, label: 'Years' }
|
||||||
|
];
|
||||||
readonly slabColumns = [
|
readonly slabColumns = [
|
||||||
'srNo',
|
'srNo',
|
||||||
'description',
|
'description',
|
||||||
@@ -89,6 +99,9 @@ export class BaseFdInterestRateChartComponent implements OnInit {
|
|||||||
'seniorRate'
|
'seniorRate'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Set when the recurring deposit's active chart is still the old one, kept in days, which this screen no longer edits. */
|
||||||
|
rdChartInDays = false;
|
||||||
|
|
||||||
forms: Record<string, FormGroup> = {};
|
forms: Record<string, FormGroup> = {};
|
||||||
slabsDataSources: Record<string, MatTableDataSource<AbstractControl>> = {};
|
slabsDataSources: Record<string, MatTableDataSource<AbstractControl>> = {};
|
||||||
activeRates: Record<string, any> = {};
|
activeRates: Record<string, any> = {};
|
||||||
@@ -110,6 +123,16 @@ export class BaseFdInterestRateChartComponent implements OnInit {
|
|||||||
this.loadAll();
|
this.loadAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The period units a slab of this chart can use. */
|
||||||
|
periodTypesFor(fdType: string): { id: number; label: string }[] {
|
||||||
|
return fdType === 'RD' ? this.rdPeriodTypes : this.periodTypes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tab's heading: RD spelled out, the fixed deposit types as they are. */
|
||||||
|
tabLabel(fdType: string): string {
|
||||||
|
return fdType === 'RD' ? 'RD (Recurring Deposit)' : fdType;
|
||||||
|
}
|
||||||
|
|
||||||
slabs(fdType: string): FormArray {
|
slabs(fdType: string): FormArray {
|
||||||
return this.forms[fdType].get('slabs') as FormArray;
|
return this.forms[fdType].get('slabs') as FormArray;
|
||||||
}
|
}
|
||||||
@@ -158,6 +181,8 @@ export class BaseFdInterestRateChartComponent implements OnInit {
|
|||||||
return 3; // Years
|
return 3; // Years
|
||||||
case 'SPECIAL':
|
case 'SPECIAL':
|
||||||
return 2; // Months
|
return 2; // Months
|
||||||
|
case 'RD':
|
||||||
|
return 2; // Months
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -264,7 +289,11 @@ export class BaseFdInterestRateChartComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.isSaving[fdType] = true;
|
this.isSaving[fdType] = true;
|
||||||
const { versionLabel, remarks, slabs } = this.forms[fdType].value;
|
const { versionLabel, remarks, slabs } = this.forms[fdType].value;
|
||||||
this.productsService.saveFdBaseInterestRates({ fdType, versionLabel, remarks, slabs }).subscribe({
|
const save$ =
|
||||||
|
fdType === 'RD'
|
||||||
|
? this.productsService.saveRdBaseInterestRates({ versionLabel, remarks, slabs })
|
||||||
|
: this.productsService.saveFdBaseInterestRates({ fdType, versionLabel, remarks, slabs });
|
||||||
|
save$.subscribe({
|
||||||
next: (response: any) => {
|
next: (response: any) => {
|
||||||
this.isSaving[fdType] = false;
|
this.isSaving[fdType] = false;
|
||||||
this.showPreview[fdType] = false;
|
this.showPreview[fdType] = false;
|
||||||
@@ -286,8 +315,13 @@ export class BaseFdInterestRateChartComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getPeriodLabel(enumVal: number): string {
|
getPeriodLabel(enumVal: number, fdType?: string): string {
|
||||||
return this.periodTypes.find((p) => p.id === enumVal)?.label ?? String(enumVal);
|
// The RD chart numbers its units differently from the fixed deposit ones, so its labels are read from its own list.
|
||||||
|
const units = fdType === 'RD' ? [
|
||||||
|
{ id: 1, label: 'Days' },
|
||||||
|
...this.rdPeriodTypes
|
||||||
|
] : this.periodTypes;
|
||||||
|
return units.find((p) => p.id === enumVal)?.label ?? String(enumVal);
|
||||||
}
|
}
|
||||||
|
|
||||||
historyForType(fdType: string): any[] {
|
historyForType(fdType: string): any[] {
|
||||||
@@ -305,13 +339,25 @@ export class BaseFdInterestRateChartComponent implements OnInit {
|
|||||||
private loadAll(): void {
|
private loadAll(): void {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.fdTypes.forEach((type) => {
|
this.fdTypes.forEach((type) => {
|
||||||
this.productsService.getFdBaseInterestRates(type).subscribe({
|
const rates$ =
|
||||||
next: (rates: any[]) => {
|
type === 'RD'
|
||||||
const active = (rates || []).find((r: any) => r.isActive);
|
? this.productsService.getRdBaseInterestRates()
|
||||||
|
: this.productsService.getFdBaseInterestRates(type);
|
||||||
|
rates$.subscribe({
|
||||||
|
next: (rates: any) => {
|
||||||
|
const versions: any[] = Array.isArray(rates) ? rates : rates ? [rates] : [];
|
||||||
|
const active = versions.find((r: any) => r.isActive);
|
||||||
this.activeRates[type] = active ?? null;
|
this.activeRates[type] = active ?? null;
|
||||||
const arr = this.slabs(type);
|
const arr = this.slabs(type);
|
||||||
arr.clear();
|
arr.clear();
|
||||||
(active?.slabs ?? []).forEach((s: any) => arr.push(this.buildSlabRow(s)));
|
// A recurring deposit is priced by months or years. A chart still held in days cannot be shown in those units, so
|
||||||
|
// it is not loaded: the rows would open with no period type, and the officer enters the chart afresh.
|
||||||
|
const rows: any[] = active?.slabs ?? [];
|
||||||
|
const inDays = type === 'RD' && rows.some((s: any) => s.periodTypeEnum === 1);
|
||||||
|
if (type === 'RD') {
|
||||||
|
this.rdChartInDays = inDays;
|
||||||
|
}
|
||||||
|
(inDays ? [] : rows).forEach((s: any) => arr.push(this.buildSlabRow(s)));
|
||||||
this.slabsDataSources[type].data = [...arr.controls];
|
this.slabsDataSources[type].data = [...arr.controls];
|
||||||
this.forms[type].patchValue({ versionLabel: '', remarks: '' });
|
this.forms[type].patchValue({ versionLabel: '', remarks: '' });
|
||||||
},
|
},
|
||||||
@@ -323,10 +369,27 @@ export class BaseFdInterestRateChartComponent implements OnInit {
|
|||||||
this.productsService.getFdBaseInterestRateHistory().subscribe({
|
this.productsService.getFdBaseInterestRateHistory().subscribe({
|
||||||
next: (h: any[]) => {
|
next: (h: any[]) => {
|
||||||
this.historyRates = h || [];
|
this.historyRates = h || [];
|
||||||
this.isLoading = false;
|
this.loadRdHistory();
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.historyRates = [];
|
this.historyRates = [];
|
||||||
|
this.loadRdHistory();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The RD chart's past versions, tagged RD so they sit under their own tab. */
|
||||||
|
private loadRdHistory(): void {
|
||||||
|
this.productsService.getRdBaseInterestRateHistory().subscribe({
|
||||||
|
next: (h: any) => {
|
||||||
|
const versions: any[] = Array.isArray(h) ? h : h ? [h] : [];
|
||||||
|
this.historyRates = [
|
||||||
|
...this.historyRates,
|
||||||
|
...versions.map((v) => ({ ...v, fdType: 'RD' }))
|
||||||
|
];
|
||||||
|
this.isLoading = false;
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+13
@@ -129,12 +129,24 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
|
|||||||
* (see DepositAccountAssembler#assembleFrom on the backend), so the product itself is locked to the
|
* (see DepositAccountAssembler#assembleFrom on the backend), so the product itself is locked to the
|
||||||
* same values here to avoid an admin configuring a combination that the backend will silently override
|
* same values here to avoid an admin configuring a combination that the backend will silently override
|
||||||
* at account-creation time.
|
* at account-creation time.
|
||||||
|
*
|
||||||
|
* Its interest is paid out every month, so nothing compounds on the principal: the Interest Calculation Type is
|
||||||
|
* locked to Simple as well. The backend refuses an FD Special product with any other value for these three
|
||||||
|
* (DepositProductDataValidator), so a request from anywhere else cannot save them either.
|
||||||
*/
|
*/
|
||||||
private applyFdTypeConstraints() {
|
private applyFdTypeConstraints() {
|
||||||
if (!this.fixedDepositProductTermsForm) return;
|
if (!this.fixedDepositProductTermsForm) return;
|
||||||
const postingCtrl = this.fixedDepositProductTermsForm.get('interestPostingPeriodType');
|
const postingCtrl = this.fixedDepositProductTermsForm.get('interestPostingPeriodType');
|
||||||
const compoundingCtrl = this.fixedDepositProductTermsForm.get('interestCompoundingPeriodType');
|
const compoundingCtrl = this.fixedDepositProductTermsForm.get('interestCompoundingPeriodType');
|
||||||
|
const calculationTypeCtrl = this.fixedDepositProductTermsForm.get('interestCalculateSelector');
|
||||||
if (this.fdType === 'SPECIAL') {
|
if (this.fdType === 'SPECIAL') {
|
||||||
|
const simple = (this.interestCalculationTypeOptionData || []).find(
|
||||||
|
(o: any) => String(o.value ?? o.name ?? '').toLowerCase() === 'simple'
|
||||||
|
);
|
||||||
|
if (calculationTypeCtrl && simple) {
|
||||||
|
calculationTypeCtrl.setValue(simple.id);
|
||||||
|
calculationTypeCtrl.disable();
|
||||||
|
}
|
||||||
// Monthly = id 4 (MONTHLY in Fineract's SavingsPostingInterestPeriodType/SavingsCompoundingInterestPeriodType enums)
|
// Monthly = id 4 (MONTHLY in Fineract's SavingsPostingInterestPeriodType/SavingsCompoundingInterestPeriodType enums)
|
||||||
const findMonthly = (data: any) => data?.find((o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4);
|
const findMonthly = (data: any) => data?.find((o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4);
|
||||||
if (postingCtrl && this.interestPostingPeriodTypeData) {
|
if (postingCtrl && this.interestPostingPeriodTypeData) {
|
||||||
@@ -154,6 +166,7 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
|
|||||||
} else {
|
} else {
|
||||||
postingCtrl?.enable();
|
postingCtrl?.enable();
|
||||||
compoundingCtrl?.enable();
|
compoundingCtrl?.enable();
|
||||||
|
calculationTypeCtrl?.enable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-4
@@ -90,13 +90,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<p class="base-rate-remarks">
|
<p class="base-rate-remarks">
|
||||||
RD tenure is counted in months, from {{ rdChart.minTenureMonths ?? '-' }} months (the Minimum Tenure on the
|
RD tenure is counted in months, from {{ rdChart.minTenureMonths ?? '-' }} months (the Minimum Tenure on
|
||||||
Terms tab). A rate below the first slab comes from the RD CBS base chart; the rest from this chart.
|
the Terms tab). Every rate comes from the RD CBS base chart.
|
||||||
</p>
|
</p>
|
||||||
@if (rdChart.shortTermRateMissing) {
|
@if (rdChart.shortTermRateMissing) {
|
||||||
<p class="base-rate-remarks">
|
<p class="base-rate-remarks">
|
||||||
The RD CBS base chart has no rate for {{ configuredMinTenureMonths }} months, so this product can only be
|
The RD CBS base chart has no rate for {{ configuredMinTenureMonths }} months, so this product can only
|
||||||
opened from {{ rdChart.minTenureMonths }} months.
|
be opened from {{ rdChart.minTenureMonths }} months.
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +127,10 @@
|
|||||||
<strong>{{ slab.annualInterestRate | number: '1.2-2' }}</strong>
|
<strong>{{ slab.annualInterestRate | number: '1.2-2' }}</strong>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="seniorCitizenRate">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Senior Citizen (%)</th>
|
||||||
|
<td mat-cell *matCellDef="let slab">{{ slab.seniorCitizenRate | number: '1.2-2' }}</td>
|
||||||
|
</ng-container>
|
||||||
<tr mat-header-row *matHeaderRowDef="rdSlabColumns"></tr>
|
<tr mat-header-row *matHeaderRowDef="rdSlabColumns"></tr>
|
||||||
<tr mat-row *matRowDef="let row; columns: rdSlabColumns"></tr>
|
<tr mat-row *matRowDef="let row; columns: rdSlabColumns"></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+14
-30
@@ -96,15 +96,20 @@ export class CreateRecurringDepositProductComponent {
|
|||||||
selectedTabIndex = 0;
|
selectedTabIndex = 0;
|
||||||
rdInterestRates: any[] = [];
|
rdInterestRates: any[] = [];
|
||||||
rdBaseSlabs: any[] = [];
|
rdBaseSlabs: any[] = [];
|
||||||
private rdChartCache: { baseSlabs: any[]; rdBaseSlabs: any[]; minTenureMonths: number | null; chart: RdChart } | null =
|
private rdChartCache: {
|
||||||
null;
|
baseSlabs: any[];
|
||||||
|
rdBaseSlabs: any[];
|
||||||
|
minTenureMonths: number | null;
|
||||||
|
chart: RdChart;
|
||||||
|
} | null = null;
|
||||||
readonly rdSlabColumns = [
|
readonly rdSlabColumns = [
|
||||||
'srNo',
|
'srNo',
|
||||||
'description',
|
'description',
|
||||||
'fromPeriod',
|
'fromPeriod',
|
||||||
'toPeriod',
|
'toPeriod',
|
||||||
'periodTypeLabel',
|
'periodTypeLabel',
|
||||||
'annualInterestRate'
|
'annualInterestRate',
|
||||||
|
'seniorCitizenRate'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,26 +124,15 @@ export class CreateRecurringDepositProductComponent {
|
|||||||
this.recurringDepositProductsTemplate = data.recurringDepositProductsTemplate;
|
this.recurringDepositProductsTemplate = data.recurringDepositProductsTemplate;
|
||||||
});
|
});
|
||||||
this.accountingRuleData = this.accounting.getAccountingRulesForSavings();
|
this.accountingRuleData = this.accounting.getAccountingRulesForSavings();
|
||||||
// Load the CBS base chart as the reference for the editable table
|
// The product's rates come from the recurring deposit's own CBS base chart (in months, with senior citizen rates)
|
||||||
this.productsService.getFdBaseInterestRates().subscribe({
|
this.productsService.getRdBaseInterestRates().subscribe({
|
||||||
next: (rates: any[]) => {
|
next: (rates: any) => {
|
||||||
this.rdInterestRates = rates || [];
|
this.rdInterestRates = Array.isArray(rates) ? rates : rates ? [rates] : [];
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.rdInterestRates = [];
|
this.rdInterestRates = [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// The RD base chart supplies the rate for 6 months to below 1 year, which the FD chart does not cover
|
|
||||||
this.productsService.getRdBaseInterestRates().subscribe({
|
|
||||||
next: (rates: any) => {
|
|
||||||
const versions: any[] = Array.isArray(rates) ? rates : rates ? [rates] : [];
|
|
||||||
const active = versions.find((version: any) => version.isActive) ?? versions[0];
|
|
||||||
this.rdBaseSlabs = active?.slabs || [];
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.rdBaseSlabs = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get recurringDepositProductDetailsForm() {
|
get recurringDepositProductDetailsForm() {
|
||||||
@@ -170,18 +164,8 @@ export class CreateRecurringDepositProductComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get activeBaseVersion() {
|
get activeBaseVersion() {
|
||||||
const chart =
|
const chart = this.rdInterestRates.find((version: any) => version.isActive) ?? this.rdInterestRates[0];
|
||||||
this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE' && version.isActive) ??
|
return chart ? { ...chart } : null;
|
||||||
this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE') ??
|
|
||||||
this.rdInterestRates[0];
|
|
||||||
if (chart) {
|
|
||||||
return {
|
|
||||||
...chart,
|
|
||||||
versionLabel: chart.versionLabel?.replace('FD', 'RD'),
|
|
||||||
remarks: chart.remarks?.replace('FD', 'RD')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The product's chart in months, from 6 months up - see rd-product-chart.ts. */
|
/** The product's chart in months, from 6 months up - see rd-product-chart.ts. */
|
||||||
|
|||||||
+4
@@ -116,6 +116,10 @@
|
|||||||
<strong>{{ slab.annualInterestRate | number: '1.2-2' }}</strong>
|
<strong>{{ slab.annualInterestRate | number: '1.2-2' }}</strong>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="seniorCitizenRate">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Senior Citizen (%)</th>
|
||||||
|
<td mat-cell *matCellDef="let slab">{{ slab.seniorCitizenRate | number: '1.2-2' }}</td>
|
||||||
|
</ng-container>
|
||||||
<tr mat-header-row *matHeaderRowDef="rdSlabColumns"></tr>
|
<tr mat-header-row *matHeaderRowDef="rdSlabColumns"></tr>
|
||||||
<tr mat-row *matRowDef="let row; columns: rdSlabColumns"></tr>
|
<tr mat-row *matRowDef="let row; columns: rdSlabColumns"></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+8
-16
@@ -95,7 +95,8 @@ export class EditRecurringDepositProductComponent {
|
|||||||
'fromPeriod',
|
'fromPeriod',
|
||||||
'toPeriod',
|
'toPeriod',
|
||||||
'periodTypeLabel',
|
'periodTypeLabel',
|
||||||
'annualInterestRate'
|
'annualInterestRate',
|
||||||
|
'seniorCitizenRate'
|
||||||
];
|
];
|
||||||
selectedTabIndex = 0;
|
selectedTabIndex = 0;
|
||||||
|
|
||||||
@@ -109,9 +110,10 @@ export class EditRecurringDepositProductComponent {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.route.data.subscribe((data: { recurringDepositProductAndTemplate: any }) => {
|
this.route.data.subscribe((data: { recurringDepositProductAndTemplate: any }) => {
|
||||||
this.recurringDepositProductsTemplate = data.recurringDepositProductAndTemplate;
|
this.recurringDepositProductsTemplate = data.recurringDepositProductAndTemplate;
|
||||||
this.productsService.getFdBaseInterestRates().subscribe({
|
// The recurring deposit's own CBS base chart, which carries the senior citizen rates
|
||||||
next: (rates: any[]) => {
|
this.productsService.getRdBaseInterestRates().subscribe({
|
||||||
this.rdInterestRates = rates || [];
|
next: (rates: any) => {
|
||||||
|
this.rdInterestRates = Array.isArray(rates) ? rates : rates ? [rates] : [];
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.rdInterestRates = [];
|
this.rdInterestRates = [];
|
||||||
@@ -150,18 +152,8 @@ export class EditRecurringDepositProductComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get activeBaseVersion() {
|
get activeBaseVersion() {
|
||||||
const chart =
|
const chart = this.rdInterestRates.find((version: any) => version.isActive) ?? this.rdInterestRates[0];
|
||||||
this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE' && version.isActive) ??
|
return chart ? { ...chart } : null;
|
||||||
this.rdInterestRates.find((version: any) => version.fdType === 'DOUBLE') ??
|
|
||||||
this.rdInterestRates[0];
|
|
||||||
if (chart) {
|
|
||||||
return {
|
|
||||||
...chart,
|
|
||||||
versionLabel: chart.versionLabel?.replace('FD', 'RD'),
|
|
||||||
remarks: chart.remarks?.replace('FD', 'RD')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeBaseSlabs() {
|
get activeBaseSlabs() {
|
||||||
|
|||||||
+20
-24
@@ -175,18 +175,13 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
|||||||
if (this.isCashOrAccrualAccounting()) {
|
if (this.isCashOrAccrualAccounting()) {
|
||||||
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
|
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
|
||||||
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
|
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
|
||||||
// Interest Paid is a liability clearing account (Dr Payable / Cr Paid, then Dr Paid / Cr RD), saved under
|
// Interest Paid sits under expenses, as on the fixed deposit and savings products (the backend finds it by name
|
||||||
// liabilities. Products saved before that kept it under expenses, so both groups are read.
|
// in any group). Products saved while it was under liabilities are read from there too, so the name is not
|
||||||
const fromLiabilities = this.extractNamedGlMapping(
|
// left behind as an "other" liability row; a liability GL chosen then has to be picked again from expenses.
|
||||||
this.extractNamedGlMapping(
|
const fromLiabilities = (glMappings?.liabilities || []).filter(
|
||||||
glMappings?.liabilities || [],
|
(mapping: any) =>
|
||||||
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
|
mapping?.accountName !== RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME &&
|
||||||
'savingsInterestPaidAccountId',
|
mapping?.accountName !== SAVINGS_INTEREST_PAID_ACCOUNT_NAME
|
||||||
this.liabilityAccountOptions
|
|
||||||
),
|
|
||||||
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
|
|
||||||
'savingsInterestPaidAccountId',
|
|
||||||
this.liabilityAccountOptions
|
|
||||||
);
|
);
|
||||||
this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
|
this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
|
||||||
const fromExpenses = this.extractNamedGlMapping(
|
const fromExpenses = this.extractNamedGlMapping(
|
||||||
@@ -277,15 +272,15 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
|||||||
|
|
||||||
const otherGlMappings = {
|
const otherGlMappings = {
|
||||||
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
|
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
|
||||||
liabilities: [
|
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
|
||||||
|
expenses: [
|
||||||
...namedAsOtherGlMapping(
|
...namedAsOtherGlMapping(
|
||||||
'savingsInterestPaidAccountId',
|
'savingsInterestPaidAccountId',
|
||||||
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
|
RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
|
||||||
this.liabilityAccountOptions
|
this.expenseAccountOptions
|
||||||
),
|
),
|
||||||
...map('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
|
...map('expensesOtherGlMappings', this.expenseAccountOptions)
|
||||||
],
|
],
|
||||||
expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
|
|
||||||
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
|
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
|
||||||
};
|
};
|
||||||
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
|
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
|
||||||
@@ -393,7 +388,8 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
|||||||
},
|
},
|
||||||
// Offered on cash as well as accrual: the interest credited at maturity or premature closure is routed
|
// Offered on cash as well as accrual: the interest credited at maturity or premature closure is routed
|
||||||
// Dr Interest On Recurring Deposits / Cr Payable, Dr Payable / Cr Interest Paid, Dr Interest Paid / Cr
|
// Dr Interest On Recurring Deposits / Cr Payable, Dr Payable / Cr Interest Paid, Dr Interest Paid / Cr
|
||||||
// the RD. Optional - a product naming neither posts straight to the control account as before.
|
// the RD. Optional - a product naming neither posts straight to the control account as before. Interest
|
||||||
|
// Paid itself is under Expenses.
|
||||||
{
|
{
|
||||||
key: 'interestPayableAccountId',
|
key: 'interestPayableAccountId',
|
||||||
label: 'Interest Payable Account',
|
label: 'Interest Payable Account',
|
||||||
@@ -401,13 +397,6 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
|||||||
controlName: 'interestPayableAccountId',
|
controlName: 'interestPayableAccountId',
|
||||||
options: this.liabilityAccountOptions
|
options: this.liabilityAccountOptions
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'savingsInterestPaidAccountId',
|
|
||||||
label: 'Recurring Deposit Interest Paid',
|
|
||||||
inputLabel: 'Recurring deposit interest paid',
|
|
||||||
controlName: 'savingsInterestPaidAccountId',
|
|
||||||
options: this.liabilityAccountOptions
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'escheatLiabilityId',
|
key: 'escheatLiabilityId',
|
||||||
label: 'Escheat Liability Account',
|
label: 'Escheat Liability Account',
|
||||||
@@ -433,6 +422,13 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
|
|||||||
controlName: 'interestOnSavingsAccountId',
|
controlName: 'interestOnSavingsAccountId',
|
||||||
options: this.expenseAccountOptions,
|
options: this.expenseAccountOptions,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'savingsInterestPaidAccountId',
|
||||||
|
label: 'Recurring Deposit Interest Paid',
|
||||||
|
inputLabel: 'Recurring deposit interest paid',
|
||||||
|
controlName: 'savingsInterestPaidAccountId',
|
||||||
|
options: this.expenseAccountOptions
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
otherMappingsControlName: 'expensesOtherGlMappings',
|
otherMappingsControlName: 'expensesOtherGlMappings',
|
||||||
|
|||||||
@@ -21,15 +21,6 @@
|
|||||||
<strong>{{ totalCount }}</strong>
|
<strong>{{ totalCount }}</strong>
|
||||||
<span>{{ totalCount === 1 ? 'product' : 'products' }}</span>
|
<span>{{ totalCount === 1 ? 'product' : 'products' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
class="pl-secondary-btn"
|
|
||||||
type="button"
|
|
||||||
[routerLink]="['/products/recurring-deposit-products/base-interest-rates']"
|
|
||||||
matTooltip="Open the CBS base interest chart"
|
|
||||||
>
|
|
||||||
<fa-icon icon="chart-line"></fa-icon>
|
|
||||||
<span>CBS Base Chart</span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
#buttonCreateRecurringProduct
|
#buttonCreateRecurringProduct
|
||||||
class="pl-create-btn"
|
class="pl-create-btn"
|
||||||
|
|||||||
+4
@@ -59,6 +59,10 @@
|
|||||||
<strong>{{ slab.annualInterestRate | number: '1.2-2' }}</strong>
|
<strong>{{ slab.annualInterestRate | number: '1.2-2' }}</strong>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="seniorCitizenRate">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Senior Citizen (%)</th>
|
||||||
|
<td mat-cell *matCellDef="let slab">{{ slab.seniorCitizenRate | number: '1.2-2' }}</td>
|
||||||
|
</ng-container>
|
||||||
<tr mat-header-row *matHeaderRowDef="slabColumns"></tr>
|
<tr mat-header-row *matHeaderRowDef="slabColumns"></tr>
|
||||||
<tr mat-row *matRowDef="let row; columns: slabColumns"></tr>
|
<tr mat-row *matRowDef="let row; columns: slabColumns"></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+2
-1
@@ -55,7 +55,8 @@ export class RdInterestRatesTabComponent {
|
|||||||
'fromPeriod',
|
'fromPeriod',
|
||||||
'toPeriod',
|
'toPeriod',
|
||||||
'periodTypeLabel',
|
'periodTypeLabel',
|
||||||
'annualInterestRate'
|
'annualInterestRate',
|
||||||
|
'seniorCitizenRate'
|
||||||
];
|
];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
+2
-23
@@ -79,25 +79,12 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>Account Type</mat-label>
|
<mat-label>Applies To</mat-label>
|
||||||
<mat-select formControlName="txnAppliesTo">
|
<mat-select formControlName="txnAppliesTo">
|
||||||
<mat-option *ngFor="let a of accountTypes" [value]="a.value">{{ a.label }}</mat-option>
|
<mat-option *ngFor="let a of appliesToOptions" [value]="a">{{ a }}</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
<mat-hint>Which accounts the rule watches</mat-hint>
|
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
|
||||||
<mat-label>Direction</mat-label>
|
|
||||||
<mat-select formControlName="txnDirection">
|
|
||||||
<mat-option *ngFor="let d of directionOptions" [value]="d.value">{{ d.label }}</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
<mat-hint>Credit = paisa aaya, Debit = paisa gaya</mat-hint>
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
<div class="rule-cash-only">
|
|
||||||
<mat-checkbox formControlName="txnCashOnly">Cash transactions only</mat-checkbox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>AML Check Status</mat-label>
|
<mat-label>AML Check Status</mat-label>
|
||||||
<mat-select formControlName="amlCheckStatus">
|
<mat-select formControlName="amlCheckStatus">
|
||||||
@@ -118,14 +105,6 @@
|
|||||||
<span>Maximum limit cannot be lower than the minimum — the rule would never match.</span>
|
<span>Maximum limit cannot be lower than the minimum — the rule would never match.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="isCcRule" class="rule-form-info">
|
|
||||||
<mat-icon>info_outline</mat-icon>
|
|
||||||
<span>
|
|
||||||
This rule will be listed under the CC chip. It does not fire on live transactions yet: the engine is not yet fed
|
|
||||||
current-account transactions.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="rule-form-note">
|
<p class="rule-form-note">
|
||||||
Amount or window details also create the matching live-transaction rule. Leave them blank for a profile-only rule.
|
Amount or window details also create the matching live-transaction rule. Leave them blank for a profile-only rule.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
-29
@@ -86,32 +86,3 @@
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-cash-only {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-form-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 8px 0 0;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid #bfdbfe;
|
|
||||||
border-left: 4px solid #1d4ed8;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1e3a8a;
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.45;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+5
-29
@@ -13,7 +13,6 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
|||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
@@ -42,7 +41,6 @@ import { ClientsService } from '../../../clients/clients.service';
|
|||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
MatCheckboxModule,
|
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatProgressSpinnerModule
|
MatProgressSpinnerModule
|
||||||
@@ -76,23 +74,11 @@ export class AddAmlRuleDialogComponent {
|
|||||||
'STRUCTURING',
|
'STRUCTURING',
|
||||||
'VELOCITY'
|
'VELOCITY'
|
||||||
];
|
];
|
||||||
/**
|
readonly appliesToOptions = [
|
||||||
* Which kind of account the rule is for. CC is a Cash Credit account (a current account with an overdraft): the engine is
|
'ALL',
|
||||||
* not yet fed current-account transactions, so a CC rule is listed under the CC chip but does not fire until it is.
|
'SAVINGS',
|
||||||
*/
|
'LOAN',
|
||||||
readonly accountTypes = [
|
'TRANSFER'
|
||||||
{ value: 'ALL', label: 'All accounts' },
|
|
||||||
{ value: 'SAVINGS', label: 'Savings' },
|
|
||||||
{ value: 'LOAN', label: 'Loan' },
|
|
||||||
{ value: 'TRANSFER', label: 'Transfer' },
|
|
||||||
{ value: 'CURRENT', label: 'CC (Cash Credit)' }
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Which way the money moves for the rule to apply. Left open, it applies to both. */
|
|
||||||
readonly directionOptions = [
|
|
||||||
{ value: '', label: 'Credit and debit (both)' },
|
|
||||||
{ value: 'CREDIT', label: 'Credit only (money comes in)' },
|
|
||||||
{ value: 'DEBIT', label: 'Debit only (money goes out)' }
|
|
||||||
];
|
];
|
||||||
readonly amlCheckStatuses = [
|
readonly amlCheckStatuses = [
|
||||||
'CLEAR',
|
'CLEAR',
|
||||||
@@ -126,8 +112,6 @@ export class AddAmlRuleDialogComponent {
|
|||||||
txnWindowDays: [0],
|
txnWindowDays: [0],
|
||||||
txnRuleType: ['THRESHOLD'],
|
txnRuleType: ['THRESHOLD'],
|
||||||
txnAppliesTo: ['ALL'],
|
txnAppliesTo: ['ALL'],
|
||||||
txnDirection: [''],
|
|
||||||
txnCashOnly: [false],
|
|
||||||
txnMinLimitAmount: [
|
txnMinLimitAmount: [
|
||||||
null as
|
null as
|
||||||
| number
|
| number
|
||||||
@@ -147,11 +131,6 @@ export class AddAmlRuleDialogComponent {
|
|||||||
dormantFlag: ['ACTIVE']
|
dormantFlag: ['ACTIVE']
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A CC rule is catalogued now and starts firing once current-account transactions reach the engine. */
|
|
||||||
get isCcRule(): boolean {
|
|
||||||
return this.ruleForm.value.txnAppliesTo === 'CURRENT';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mirrors the backend band check so the officer is told before the round trip. */
|
/** Mirrors the backend band check so the officer is told before the round trip. */
|
||||||
get bandInverted(): boolean {
|
get bandInverted(): boolean {
|
||||||
const min = this.ruleForm.value.txnMinLimitAmount;
|
const min = this.ruleForm.value.txnMinLimitAmount;
|
||||||
@@ -200,9 +179,6 @@ export class AddAmlRuleDialogComponent {
|
|||||||
txnWindowDays: v.txnWindowDays ? v.txnWindowDays : null,
|
txnWindowDays: v.txnWindowDays ? v.txnWindowDays : null,
|
||||||
txnRuleType: v.txnRuleType,
|
txnRuleType: v.txnRuleType,
|
||||||
txnAppliesTo: v.txnAppliesTo,
|
txnAppliesTo: v.txnAppliesTo,
|
||||||
txnDirection: v.txnDirection ? v.txnDirection : null,
|
|
||||||
txnCashOnly: !!v.txnCashOnly,
|
|
||||||
ccRelevant: v.txnAppliesTo === 'CURRENT',
|
|
||||||
txnMinLimitAmount: amount(v.txnMinLimitAmount),
|
txnMinLimitAmount: amount(v.txnMinLimitAmount),
|
||||||
txnMaxLimitAmount: amount(v.txnMaxLimitAmount)
|
txnMaxLimitAmount: amount(v.txnMaxLimitAmount)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,10 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="cc-check">
|
||||||
|
<input type="checkbox" [(ngModel)]="ccOnly" />
|
||||||
|
CC (overdraft) accounts only
|
||||||
|
</label>
|
||||||
<span class="cc-asof">
|
<span class="cc-asof">
|
||||||
@if (asOf) {
|
@if (asOf) {
|
||||||
{{ from | date: 'dd MMM yyyy' }} to {{ asOf | date: 'dd MMM yyyy' }}
|
{{ from | date: 'dd MMM yyyy' }} to {{ asOf | date: 'dd MMM yyyy' }}
|
||||||
@@ -47,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="cc-card">
|
<div class="cc-card">
|
||||||
<div class="cc-card__n">{{ shown.length }}</div>
|
<div class="cc-card__n">{{ shown.length }}</div>
|
||||||
<div class="cc-card__l">CC accounts</div>
|
<div class="cc-card__l">Accounts shown ({{ ccCount }} CC)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -57,8 +60,12 @@
|
|||||||
<div class="cc-note cc-note--warn">{{ error }}</div>
|
<div class="cc-note cc-note--warn">{{ error }}</div>
|
||||||
} @else if (!shown.length) {
|
} @else if (!shown.length) {
|
||||||
<div class="cc-note">
|
<div class="cc-note">
|
||||||
No CC account in your branch yet. A CC account is a current account opened on a product that allows an overdraft
|
@if (ccOnly && !ccCount) {
|
||||||
(Client page, Applications, New CC Account).
|
No CC account yet. A CC account is a current account opened with the overdraft facility switched on; untick the
|
||||||
|
box to see every current account.
|
||||||
|
} @else {
|
||||||
|
No active current account in your branch.
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="cc-table-wrap">
|
<div class="cc-table-wrap">
|
||||||
@@ -77,13 +84,11 @@
|
|||||||
<th class="r">In ÷ declared</th>
|
<th class="r">In ÷ declared</th>
|
||||||
<th class="r">Cash in</th>
|
<th class="r">Cash in</th>
|
||||||
<th>Last txn</th>
|
<th>Last txn</th>
|
||||||
<th>CC rules</th>
|
|
||||||
<th class="r">Details</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (a of shown; track a.accountId; let i = $index) {
|
@for (a of shown; track a.accountId; let i = $index) {
|
||||||
<tr class="cc-row" (click)="openTrail(a, 'analysis')">
|
<tr>
|
||||||
<td>{{ i + 1 }}</td>
|
<td>{{ i + 1 }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="cc-name">
|
<div class="cc-name">
|
||||||
@@ -108,7 +113,7 @@
|
|||||||
₹{{ a.debits | number: '1.2-2' }}
|
₹{{ a.debits | number: '1.2-2' }}
|
||||||
<div class="cc-sub">{{ a.debitCount }} txn</div>
|
<div class="cc-sub">{{ a.debitCount }} txn</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="r" [class.in]="a.net > 0" [class.out]="a.net < 0">{{ rs(a.net) }}</td>
|
<td class="r" [class.in]="a.net > 0" [class.out]="a.net < 0">₹{{ a.net | number: '1.2-2' }}</td>
|
||||||
<td class="r">
|
<td class="r">
|
||||||
@if (a.creditsVsDeclared != null) {
|
@if (a.creditsVsDeclared != null) {
|
||||||
<span
|
<span
|
||||||
@@ -124,26 +129,6 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="r">{{ a.cashCreditPercent != null ? a.cashCreditPercent + '%' : '—' }}</td>
|
<td class="r">{{ a.cashCreditPercent != null ? a.cashCreditPercent + '%' : '—' }}</td>
|
||||||
<td>{{ a.lastTxnDate ? (a.lastTxnDate | date: 'dd MMM yyyy') : '—' }}</td>
|
<td>{{ a.lastTxnDate ? (a.lastTxnDate | date: 'dd MMM yyyy') : '—' }}</td>
|
||||||
<td
|
|
||||||
class="cc-rules-cell"
|
|
||||||
(click)="openTrail(a, 'rules'); $event.stopPropagation()"
|
|
||||||
title="See which CC rules this account hits"
|
|
||||||
>
|
|
||||||
<span class="cc-hit" [class.cc-hit--on]="a.rulesHit > 0">{{ a.rulesHit }} hit</span>
|
|
||||||
<div class="cc-sub">{{ a.rulesNoHit }} no hit · {{ a.rulesNotChecked }} not checked</div>
|
|
||||||
</td>
|
|
||||||
<td class="r cc-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cc-trail-btn cc-trail-btn--main"
|
|
||||||
(click)="openTrail(a, 'analysis'); $event.stopPropagation()"
|
|
||||||
>
|
|
||||||
Analysis
|
|
||||||
</button>
|
|
||||||
<button type="button" class="cc-trail-btn" (click)="openTrail(a, 'trail'); $event.stopPropagation()">
|
|
||||||
Trail
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -154,442 +139,3 @@
|
|||||||
{{ spikeMultiple }}× or more is highlighted for a look. It is an indicative starting value, not a finding.
|
{{ spikeMultiple }}× or more is highlighted for a look. It is an indicative starting value, not a finding.
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Where one account's money came from and went, and what it adds up to. Opened from a row above; declared here so it
|
|
||||||
keeps this component's state and styles. Credits are written Cr and debits Dr, as in the bank's books. -->
|
|
||||||
<ng-template #trailDialog>
|
|
||||||
<div class="cc-dialog">
|
|
||||||
@if (trailLoading) {
|
|
||||||
<div class="cc-note">Loading the account's money trail…</div>
|
|
||||||
} @else if (trailError) {
|
|
||||||
<div class="cc-note cc-note--warn">{{ trailError }}</div>
|
|
||||||
<div class="cc-dialog__bar"><button type="button" class="cc-btn" (click)="closeTrail()">Close</button></div>
|
|
||||||
} @else if (trail) {
|
|
||||||
<div class="cc-dialog__head">
|
|
||||||
<div>
|
|
||||||
<div class="cc-dialog__title">
|
|
||||||
{{ trail.clientName }}
|
|
||||||
<span class="cc-badge" [class.cc-badge--cc]="trail.isCc">{{ trail.isCc ? 'CC' : 'Current' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="cc-sub mono">{{ trail.accountNo }} · {{ trail.branchName }}</div>
|
|
||||||
<div class="cc-sub">
|
|
||||||
{{ trail.from | date: 'dd MMM yyyy' }} to {{ trail.asOf | date: 'dd MMM yyyy' }} · Sanctioned limit ₹{{
|
|
||||||
trail.sanctionedLimit | number: '1.0-0'
|
|
||||||
}}
|
|
||||||
· Closing balance <strong>{{ drcr(trail.balance) }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="cc-btn" (click)="closeTrail()">Close</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cc-legend">
|
|
||||||
<span><strong class="in">Cr (credit)</strong> = money received into the account.</span>
|
|
||||||
<span><strong class="out">Dr (debit)</strong> = money paid out of the account.</span>
|
|
||||||
<span
|
|
||||||
>On a CC account, a <strong class="out">Dr balance</strong> means the customer has drawn on the overdraft,
|
|
||||||
that is, owes the bank.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cc-tabs">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cc-tab"
|
|
||||||
[class.cc-tab--on]="trailView === 'analysis'"
|
|
||||||
(click)="trailView = 'analysis'"
|
|
||||||
>
|
|
||||||
Analysis
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cc-tab"
|
|
||||||
[class.cc-tab--on]="trailView === 'rules'"
|
|
||||||
(click)="trailView = 'rules'; ruleFilter = 'HIT'"
|
|
||||||
>
|
|
||||||
CC rules ({{ ruleCount('HIT') }} hit)
|
|
||||||
</button>
|
|
||||||
<button type="button" class="cc-tab" [class.cc-tab--on]="trailView === 'trail'" (click)="trailView = 'trail'">
|
|
||||||
Every transaction ({{ trail.transactions.length }})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (trailView === 'rules') {
|
|
||||||
<!-- Which CC rules this account hits, and which it does not -->
|
|
||||||
<div class="cc-rulesbar">
|
|
||||||
<button type="button" class="cc-chip" [class.cc-chip--on]="ruleFilter === 'HIT'" (click)="ruleFilter = 'HIT'">
|
|
||||||
Hit <span class="cc-chip__n">{{ ruleCount('HIT') }}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cc-chip"
|
|
||||||
[class.cc-chip--on]="ruleFilter === 'NO_HIT'"
|
|
||||||
(click)="ruleFilter = 'NO_HIT'"
|
|
||||||
>
|
|
||||||
No hit <span class="cc-chip__n">{{ ruleCount('NO_HIT') }}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="cc-chip"
|
|
||||||
[class.cc-chip--on]="ruleFilter === 'NOT_CHECKED'"
|
|
||||||
(click)="ruleFilter = 'NOT_CHECKED'"
|
|
||||||
>
|
|
||||||
Not checked <span class="cc-chip__n">{{ ruleCount('NOT_CHECKED') }}</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="cc-chip" [class.cc-chip--on]="ruleFilter === 'ALL'" (click)="ruleFilter = 'ALL'">
|
|
||||||
All <span class="cc-chip__n">{{ ruleCount('') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="cc-hint cc-hint--top">
|
|
||||||
<strong>Hit</strong> = this account's transactions trigger the rule. <strong>No hit</strong> = the rule was
|
|
||||||
checked and not triggered. <strong>Not checked</strong> = the rule needs an officer's judgement or data the
|
|
||||||
system does not hold yet, so it is not shown as passed.
|
|
||||||
</p>
|
|
||||||
<div class="cc-table-wrap">
|
|
||||||
<table class="cc-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Rule</th>
|
|
||||||
<th>Kind</th>
|
|
||||||
<th>Risk</th>
|
|
||||||
<th>Result</th>
|
|
||||||
<th>Why</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (r of filteredRuleChecks; track r.ruleId) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
{{ r.name }}
|
|
||||||
@if (r.onTrial) {
|
|
||||||
<span class="cc-trial">TRIAL</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>{{ r.kind }}</td>
|
|
||||||
<td>{{ riskLabel(r.riskCategory) }}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="cc-res"
|
|
||||||
[class.cc-res--hit]="r.status === 'HIT'"
|
|
||||||
[class.cc-res--ok]="r.status === 'NO_HIT'"
|
|
||||||
[class.cc-res--na]="r.status === 'NOT_CHECKED'"
|
|
||||||
>
|
|
||||||
{{ r.status === 'HIT' ? 'HIT' : r.status === 'NO_HIT' ? 'No hit' : 'Not checked' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="cc-sub-cell">{{ r.detail }}</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="cc-empty">No rule in this group.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
} @else if (trailView === 'analysis') {
|
|
||||||
<!-- In words -->
|
|
||||||
<div class="cc-story">
|
|
||||||
<div class="cc-story__title">In short</div>
|
|
||||||
@for (line of story; track $index) {
|
|
||||||
<p>{{ line }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- The figures -->
|
|
||||||
<div class="cc-kpis">
|
|
||||||
<div class="cc-kpi cc-kpi--in">
|
|
||||||
<div class="cc-kpi__t">Total credits (Cr)</div>
|
|
||||||
<div class="cc-kpi__n">₹{{ trail.summary.in | number: '1.2-2' }}</div>
|
|
||||||
<div class="cc-kpi__l">
|
|
||||||
{{ trail.summary.inCount }} credit{{ trail.summary.inCount === 1 ? '' : 's' }} · money received
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cc-kpi cc-kpi--out">
|
|
||||||
<div class="cc-kpi__t">Total debits (Dr)</div>
|
|
||||||
<div class="cc-kpi__n">₹{{ trail.summary.out | number: '1.2-2' }}</div>
|
|
||||||
<div class="cc-kpi__l">
|
|
||||||
{{ trail.summary.outCount }} debit{{ trail.summary.outCount === 1 ? '' : 's' }} · money paid out
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cc-kpi">
|
|
||||||
<div class="cc-kpi__t">Net (Cr − Dr)</div>
|
|
||||||
<div class="cc-kpi__n" [class.in]="trail.summary.net > 0" [class.out]="trail.summary.net < 0">
|
|
||||||
{{ drcr(trail.summary.net) }}
|
|
||||||
</div>
|
|
||||||
<div class="cc-kpi__l">
|
|
||||||
{{
|
|
||||||
trail.summary.net < 0
|
|
||||||
? 'Debits were more than credits'
|
|
||||||
: trail.summary.net > 0
|
|
||||||
? 'Credits were more than debits'
|
|
||||||
: 'Credits equal debits'
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cc-kpi">
|
|
||||||
<div class="cc-kpi__t">Closing balance</div>
|
|
||||||
<div class="cc-kpi__n" [class.in]="trail.balance > 0" [class.out]="trail.balance < 0">
|
|
||||||
{{ drcr(trail.balance) }}
|
|
||||||
</div>
|
|
||||||
<div class="cc-kpi__l">
|
|
||||||
{{
|
|
||||||
trail.balance < 0
|
|
||||||
? 'Overdrawn: the customer owes the bank'
|
|
||||||
: trail.balance > 0
|
|
||||||
? "The customer's own money"
|
|
||||||
: 'Nil'
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cc-kpi">
|
|
||||||
<div class="cc-kpi__t">Lowest balance</div>
|
|
||||||
<div class="cc-kpi__n">{{ drcr(trail.summary.lowestBalance, '1.0-0') }}</div>
|
|
||||||
<div class="cc-kpi__l">
|
|
||||||
{{
|
|
||||||
trail.summary.peakLimitUsedPercent != null
|
|
||||||
? trail.summary.peakLimitUsedPercent + '% of the limit used at the worst point'
|
|
||||||
: 'Never overdrawn'
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cc-facts">
|
|
||||||
<span
|
|
||||||
>Active days: <strong>{{ trail.summary.activeDays }}</strong></span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
>Average credit:
|
|
||||||
<strong>{{
|
|
||||||
trail.summary.averageIn != null ? '₹' + (trail.summary.averageIn | number: '1.0-0') : '—'
|
|
||||||
}}</strong></span
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
>Average debit:
|
|
||||||
<strong>{{
|
|
||||||
trail.summary.averageOut != null ? '₹' + (trail.summary.averageOut | number: '1.0-0') : '—'
|
|
||||||
}}</strong></span
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
Declared turnover for the window:
|
|
||||||
<strong>{{
|
|
||||||
trail.summary.declaredForWindow != null
|
|
||||||
? '₹' + (trail.summary.declaredForWindow | number: '1.0-0')
|
|
||||||
: 'not declared'
|
|
||||||
}}</strong>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
Credits ÷ declared:
|
|
||||||
<strong>{{ trail.summary.creditsVsDeclared != null ? trail.summary.creditsVsDeclared + '×' : '—' }}</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- What stands out -->
|
|
||||||
<div class="cc-group__title cc-group__title--pad">What stands out</div>
|
|
||||||
<div class="cc-obs">
|
|
||||||
@for (o of trail.observations; track $index) {
|
|
||||||
<div class="cc-ob" [ngClass]="'cc-ob--' + o.level.toLowerCase()">
|
|
||||||
<span class="cc-ob__lvl">{{ o.level }}</span>
|
|
||||||
<div>
|
|
||||||
<div class="cc-ob__t">{{ o.title }}</div>
|
|
||||||
<div class="cc-ob__d">{{ o.detail }}</div>
|
|
||||||
@if (o.why) {
|
|
||||||
<div class="cc-ob__w"><strong>Why it matters:</strong> {{ o.why }}</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cc-groups">
|
|
||||||
<!-- By mode -->
|
|
||||||
<div class="cc-group">
|
|
||||||
<div class="cc-group__title">By mode of transaction</div>
|
|
||||||
<table class="cc-mini">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Mode</th>
|
|
||||||
<th class="r">Credit (Cr)</th>
|
|
||||||
<th class="r">Debit (Dr)</th>
|
|
||||||
<th class="r">Net</th>
|
|
||||||
<th class="r">Txns</th>
|
|
||||||
<th class="r">Share</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (m of trail.byMode; track m.label) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ m.label }}</td>
|
|
||||||
<td class="r in">₹{{ m.in | number: '1.0-0' }}</td>
|
|
||||||
<td class="r out">₹{{ m.out | number: '1.0-0' }}</td>
|
|
||||||
<td class="r">{{ drcr(m.in - m.out, '1.0-0') }}</td>
|
|
||||||
<td class="r">{{ m.count }}</td>
|
|
||||||
<td class="r">
|
|
||||||
<span class="cc-bar"><span class="cc-bar__fill" [style.width.%]="m.sharePercent"></span></span>
|
|
||||||
{{ m.sharePercent }}%
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="cc-empty">No transactions in this window.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="cc-hint">Share = this mode's part of all the money that moved (credits + debits).</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- By the party on the other side -->
|
|
||||||
<div class="cc-group">
|
|
||||||
<div class="cc-group__title">Who the money came from (Cr) and went to (Dr)</div>
|
|
||||||
<table class="cc-mini">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Account / Bank</th>
|
|
||||||
<th class="r">Credit (Cr)</th>
|
|
||||||
<th class="r">Debit (Dr)</th>
|
|
||||||
<th class="r">Net</th>
|
|
||||||
<th class="r">Txns</th>
|
|
||||||
<th class="r">Share</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (p of trail.byCounterparty; track $index) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<span class="mono">{{ p.label }}</span>
|
|
||||||
<div class="cc-sub">{{ p.sub || '' }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="r in">₹{{ p.in | number: '1.0-0' }}</td>
|
|
||||||
<td class="r out">₹{{ p.out | number: '1.0-0' }}</td>
|
|
||||||
<td class="r">{{ drcr(p.in - p.out, '1.0-0') }}</td>
|
|
||||||
<td class="r">{{ p.count }}</td>
|
|
||||||
<td class="r">
|
|
||||||
<span class="cc-bar"><span class="cc-bar__fill" [style.width.%]="p.sharePercent"></span></span>
|
|
||||||
{{ p.sharePercent }}%
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="cc-empty">—</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="cc-hint">Cr from a party = it paid this account. Dr to a party = this account paid it.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Week by week -->
|
|
||||||
<div class="cc-group__title cc-group__title--pad">Week by week</div>
|
|
||||||
<table class="cc-mini cc-weeks">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Week starting</th>
|
|
||||||
<th class="r">Credit (Cr)</th>
|
|
||||||
<th class="r">Debit (Dr)</th>
|
|
||||||
<th class="r">Net for the week</th>
|
|
||||||
<th class="r">Txns</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (w of trail.timeline; track w.label) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ w.label | date: 'dd MMM yyyy' }}</td>
|
|
||||||
<td class="r in">₹{{ w.in | number: '1.0-0' }}</td>
|
|
||||||
<td class="r out">₹{{ w.out | number: '1.0-0' }}</td>
|
|
||||||
<td class="r">{{ drcr(w.in - w.out, '1.0-0') }}</td>
|
|
||||||
<td class="r">{{ w.count }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- The biggest two -->
|
|
||||||
<div class="cc-big">
|
|
||||||
@if (trail.summary.largestIn) {
|
|
||||||
<div class="cc-big__item">
|
|
||||||
Largest credit (Cr): <strong class="in">₹{{ trail.summary.largestIn.amount | number: '1.2-2' }}</strong
|
|
||||||
>, received from {{ trail.summary.largestIn.party }} by {{ trail.summary.largestIn.mode }} on
|
|
||||||
{{ trail.summary.largestIn.date | date: 'dd MMM yyyy' }}.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (trail.summary.largestOut) {
|
|
||||||
<div class="cc-big__item">
|
|
||||||
Largest debit (Dr): <strong class="out">₹{{ trail.summary.largestOut.amount | number: '1.2-2' }}</strong
|
|
||||||
>, paid to {{ trail.summary.largestOut.party }} by {{ trail.summary.largestOut.mode }} on
|
|
||||||
{{ trail.summary.largestOut.date | date: 'dd MMM yyyy' }}.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<p class="cc-foot">
|
|
||||||
Observations use fixed starting values (for example 3× the declared turnover, a credit mostly paid out again
|
|
||||||
within 3 days, one party above half of all the money moved). They point at something worth a look; they are
|
|
||||||
not findings, and the bank's compliance team sets the real thresholds.
|
|
||||||
</p>
|
|
||||||
} @else {
|
|
||||||
<div class="cc-table-wrap">
|
|
||||||
<table class="cc-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Cr / Dr</th>
|
|
||||||
<th>Mode</th>
|
|
||||||
<th>Other side</th>
|
|
||||||
<th>Reference</th>
|
|
||||||
<th class="r">Credit (Cr)</th>
|
|
||||||
<th class="r">Debit (Dr)</th>
|
|
||||||
<th class="r">Balance after</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (t of trail.transactions; track t.id) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ t.date | date: 'dd MMM yyyy' }}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="cc-dir"
|
|
||||||
[class.cc-dir--in]="t.direction === 'IN'"
|
|
||||||
[class.cc-dir--out]="t.direction === 'OUT'"
|
|
||||||
>
|
|
||||||
{{ t.direction === 'IN' ? 'Cr · received' : 'Dr · paid out' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ t.mode }}
|
|
||||||
@if (t.chequeNo) {
|
|
||||||
<div class="cc-sub">No. {{ t.chequeNo }}</div>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="mono">{{ t.counterpartyAccount || '—' }}</span>
|
|
||||||
<div class="cc-sub">
|
|
||||||
{{ t.counterpartyBank || '' }}{{ t.counterpartyIfsc ? ' · ' + t.counterpartyIfsc : '' }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="mono">{{ t.reference || '—' }}</span>
|
|
||||||
<div class="cc-sub">{{ t.remarks }}</div>
|
|
||||||
</td>
|
|
||||||
<td class="r in">{{ t.direction === 'IN' ? '₹' + (t.amount | number: '1.2-2') : '' }}</td>
|
|
||||||
<td class="r out">{{ t.direction === 'OUT' ? '₹' + (t.amount | number: '1.2-2') : '' }}</td>
|
|
||||||
<td class="r">{{ drcr(t.balanceAfter) }}</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
|
||||||
<tr>
|
|
||||||
<td colspan="8" class="cc-empty">No transactions in this window.</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<p class="cc-foot">
|
|
||||||
“Mode” and the other side are what the payment recorded: cash, cheque (by its number), and NEFT, RTGS, IMPS,
|
|
||||||
UPI, demand draft or internal transfer where the payment says so. Where it recorded nothing, it shows as “Not
|
|
||||||
recorded”. “Balance after” is the account's balance once that transaction was posted: Dr when overdrawn, Cr
|
|
||||||
when in credit.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@
|
|||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
||||||
th {
|
th {
|
||||||
padding: 12px 6px;
|
padding: 12px 10px;
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
padding: 10px 6px;
|
padding: 10px;
|
||||||
border-top: 1px solid #f1f5f9;
|
border-top: 1px solid #f1f5f9;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
@@ -232,539 +232,3 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cc-row {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-trail-btn,
|
|
||||||
.cc-btn {
|
|
||||||
padding: 5px 12px;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
color: #1e3a8a;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #eff6ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The money-trail popup. */
|
|
||||||
.cc-dialog {
|
|
||||||
padding: 20px 28px 24px;
|
|
||||||
|
|
||||||
&__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__bar {
|
|
||||||
margin-top: 12px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-groups {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-group {
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #475569;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
|
|
||||||
&--pad {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-mini {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
|
|
||||||
th {
|
|
||||||
padding: 6px 10px;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 7px 10px;
|
|
||||||
border-top: 1px solid #f1f5f9;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #1e293b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.r {
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.in {
|
|
||||||
color: #15803d;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.out {
|
|
||||||
color: #b91c1c;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-empty {
|
|
||||||
padding: 12px;
|
|
||||||
color: #64748b;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-dir {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
|
|
||||||
&--in {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--out {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The two buttons are stacked so the Details column stays narrow and the table fits the screen. */
|
|
||||||
.cc-actions {
|
|
||||||
width: 84px;
|
|
||||||
|
|
||||||
.cc-trail-btn {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-trail-btn + .cc-trail-btn {
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-trail-btn--main {
|
|
||||||
background: #1d4ed8;
|
|
||||||
border-color: #1d4ed8;
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #1e40af;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
border-bottom: 1px solid #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-tab {
|
|
||||||
padding: 8px 14px;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
background: transparent;
|
|
||||||
color: #64748b;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&--on {
|
|
||||||
border-bottom-color: #1d4ed8;
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-kpis {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-kpi {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-left: 4px solid #1a237e;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
&--in {
|
|
||||||
border-left-color: #15803d;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--out {
|
|
||||||
border-left-color: #b91c1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__n {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #0f172a;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
|
|
||||||
&.in {
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.out {
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__l {
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-obs {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-ob {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-left: 4px solid #94a3b8;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
|
|
||||||
&__lvl {
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 58px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #e2e8f0;
|
|
||||||
color: #475569;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__t {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__d {
|
|
||||||
margin-top: 1px;
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--high {
|
|
||||||
border-left-color: #dc2626;
|
|
||||||
background: #fef2f2;
|
|
||||||
|
|
||||||
.cc-ob__lvl {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--medium {
|
|
||||||
border-left-color: #f59e0b;
|
|
||||||
background: #fffbeb;
|
|
||||||
|
|
||||||
.cc-ob__lvl {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&--ok {
|
|
||||||
border-left-color: #16a34a;
|
|
||||||
background: #f0fdf4;
|
|
||||||
|
|
||||||
.cc-ob__lvl {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-bar {
|
|
||||||
display: inline-block;
|
|
||||||
width: 54px;
|
|
||||||
height: 6px;
|
|
||||||
margin-right: 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: #e2e8f0;
|
|
||||||
vertical-align: middle;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&__fill {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
background: #1d4ed8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-weeks {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-big {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #334155;
|
|
||||||
|
|
||||||
.in {
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.out {
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px 22px;
|
|
||||||
margin: 0 0 14px;
|
|
||||||
padding: 9px 14px;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8fafc;
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: #475569;
|
|
||||||
|
|
||||||
.in {
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.out {
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-story {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
border: 1px solid #bfdbfe;
|
|
||||||
border-left: 4px solid #1d4ed8;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1e3a8a;
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0 0 6px;
|
|
||||||
font-size: 13.5px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-kpi__t {
|
|
||||||
margin-bottom: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #334155;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-facts {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px 26px;
|
|
||||||
margin: -4px 0 16px;
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: #475569;
|
|
||||||
|
|
||||||
strong {
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-ob__w {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: #64748b;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-hint {
|
|
||||||
margin: 0;
|
|
||||||
padding: 6px 10px 8px;
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-rules-cell {
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-hit {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 1px 9px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #15803d;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 800;
|
|
||||||
|
|
||||||
&--on {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-rulesbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 5px 14px;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #fff;
|
|
||||||
color: #334155;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&--on {
|
|
||||||
border-color: #1d4ed8;
|
|
||||||
background: #1d4ed8;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__n {
|
|
||||||
padding: 0 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(0 0 0 / 10%);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-hint--top {
|
|
||||||
padding: 2px 2px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-res {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
color: #475569;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&--hit {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--ok {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #15803d;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--na {
|
|
||||||
background: #e2e8f0;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-trial {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 6px;
|
|
||||||
padding: 0 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cc-sub-cell {
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: #475569;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A negative amount stays on one line: -₹1,00,000.00, not a minus on one line and the amount on the next. */
|
|
||||||
.cc-table td.r {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,12 +6,11 @@
|
|||||||
* 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 { CommonModule, formatNumber, formatDate } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
|
|
||||||
import { AmlService } from '../../../aml/aml.service';
|
import { AmlService } from '../../../aml/aml.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,18 +31,6 @@ import { AmlService } from '../../../aml/aml.service';
|
|||||||
})
|
})
|
||||||
export class AmlCcTrackingComponent implements OnInit {
|
export class AmlCcTrackingComponent implements OnInit {
|
||||||
private amlService = inject(AmlService);
|
private amlService = inject(AmlService);
|
||||||
private dialog = inject(MatDialog);
|
|
||||||
|
|
||||||
/** The money trail of one account, in a popup. */
|
|
||||||
@ViewChild('trailDialog') trailDialogTpl!: TemplateRef<unknown>;
|
|
||||||
private trailRef: MatDialogRef<unknown> | null = null;
|
|
||||||
trail: any = null;
|
|
||||||
trailLoading = false;
|
|
||||||
/** What the popup is showing: the analysis, or every transaction. */
|
|
||||||
trailView: 'analysis' | 'rules' | 'trail' = 'analysis';
|
|
||||||
/** Which of the account's CC rule checks to list: all of them, or only those that hit, did not hit, or were not checked. */
|
|
||||||
ruleFilter: 'ALL' | 'HIT' | 'NO_HIT' | 'NOT_CHECKED' = 'ALL';
|
|
||||||
trailError = '';
|
|
||||||
|
|
||||||
readonly windows = [
|
readonly windows = [
|
||||||
{ days: 7, label: 'Last 7 days' },
|
{ days: 7, label: 'Last 7 days' },
|
||||||
@@ -51,6 +38,8 @@ export class AmlCcTrackingComponent implements OnInit {
|
|||||||
{ days: 90, label: 'Last 90 days' }
|
{ days: 90, label: 'Last 90 days' }
|
||||||
];
|
];
|
||||||
days = 30;
|
days = 30;
|
||||||
|
/** CC accounts only, or every current account (a plain current account is not hidden by default). */
|
||||||
|
ccOnly = false;
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -83,9 +72,12 @@ export class AmlCcTrackingComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The server returns only Cash Credit accounts. */
|
|
||||||
get shown(): any[] {
|
get shown(): any[] {
|
||||||
return this.accounts;
|
return this.ccOnly ? this.accounts.filter((a) => a.isCc) : this.accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
get ccCount(): number {
|
||||||
|
return this.accounts.filter((a) => a.isCc).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
get totalIn(): number {
|
get totalIn(): number {
|
||||||
@@ -96,126 +88,6 @@ export class AmlCcTrackingComponent implements OnInit {
|
|||||||
return this.shown.reduce((sum, a) => sum + (Number(a.debits) || 0), 0);
|
return this.shown.reduce((sum, a) => sum + (Number(a.debits) || 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Opens where one account's money came from and went: each transaction, its mode, and the party on the other side. */
|
|
||||||
openTrail(account: any, view: 'analysis' | 'rules' | 'trail' = 'analysis'): void {
|
|
||||||
this.trailView = view;
|
|
||||||
this.ruleFilter = view === 'rules' ? 'HIT' : 'ALL';
|
|
||||||
this.trail = null;
|
|
||||||
this.trailError = '';
|
|
||||||
this.trailLoading = true;
|
|
||||||
this.amlService.getCcAccountTrail(account.accountId, this.days).subscribe({
|
|
||||||
next: (res) => {
|
|
||||||
this.trail = res;
|
|
||||||
this.trailLoading = false;
|
|
||||||
},
|
|
||||||
error: (err: any) => {
|
|
||||||
this.trailLoading = false;
|
|
||||||
this.trailError = err?.error?.defaultUserMessage || 'Could not load the money trail for this account.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.trailRef?.close();
|
|
||||||
this.trailRef = this.dialog.open(this.trailDialogTpl, {
|
|
||||||
width: '1500px',
|
|
||||||
maxWidth: '98vw',
|
|
||||||
height: '94vh',
|
|
||||||
maxHeight: '96vh',
|
|
||||||
autoFocus: false
|
|
||||||
});
|
|
||||||
this.trailRef.afterClosed().subscribe(() => (this.trailRef = null));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How many of the open account's CC rule checks have the given result. */
|
|
||||||
ruleCount(status: string): number {
|
|
||||||
return (this.trail?.ruleChecks || []).filter((r: any) => !status || r.status === status).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The open account's CC rule checks, narrowed to the chosen result. */
|
|
||||||
get filteredRuleChecks(): any[] {
|
|
||||||
const all: any[] = this.trail?.ruleChecks || [];
|
|
||||||
return this.ruleFilter === 'ALL' ? all : all.filter((r) => r.status === this.ruleFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
riskLabel(category: number): string {
|
|
||||||
return category === 3 ? 'High' : category === 2 ? 'Medium' : category === 1 ? 'Low' : '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
closeTrail(): void {
|
|
||||||
this.trailRef?.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A balance the way a bank writes it: Dr for a debit balance (on a CC account, the overdraft drawn - the customer owes
|
|
||||||
* the bank), Cr for a credit balance (the customer's own money), and a bare amount for nil.
|
|
||||||
*/
|
|
||||||
drcr(value: number | string | null | undefined, digits = '1.2-2'): string {
|
|
||||||
if (value === null || value === undefined || value === '') return '—';
|
|
||||||
const n = Number(value);
|
|
||||||
if (n === 0) return `₹${formatNumber(0, 'en-US', digits)}`;
|
|
||||||
return `₹${formatNumber(Math.abs(n), 'en-US', digits)} ${n < 0 ? 'Dr' : 'Cr'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The account's activity told in plain words, from the same figures the cards and tables show. */
|
|
||||||
get story(): string[] {
|
|
||||||
const t = this.trail;
|
|
||||||
if (!t?.summary) return [];
|
|
||||||
const s = t.summary;
|
|
||||||
const day = (iso: string) => formatDate(iso, 'dd MMM yyyy', 'en-US');
|
|
||||||
const money = (n: number) => `₹${formatNumber(Math.abs(Number(n)), 'en-US', '1.2-2')}`;
|
|
||||||
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
lines.push(
|
|
||||||
`From ${day(t.from)} to ${day(t.asOf)}, this ${t.isCc ? 'Cash Credit' : 'current'} account received ${money(s.in)} (Cr) in ` +
|
|
||||||
`${plural(s.inCount, 'credit')} and paid out ${money(s.out)} (Dr) in ${plural(s.outCount, 'debit')}.`
|
|
||||||
);
|
|
||||||
const net = Number(s.net);
|
|
||||||
lines.push(
|
|
||||||
net < 0
|
|
||||||
? `Debits were ${money(net)} more than credits, so more money went out than came in.`
|
|
||||||
: net > 0
|
|
||||||
? `Credits were ${money(net)} more than debits, so more money came in than went out.`
|
|
||||||
: 'Credits and debits were equal.'
|
|
||||||
);
|
|
||||||
const bal = Number(t.balance);
|
|
||||||
const limit = Number(t.sanctionedLimit) || 0;
|
|
||||||
if (bal < 0) {
|
|
||||||
lines.push(
|
|
||||||
`The account now stands at ${money(bal)} Dr: it is overdrawn, which means the customer has used ${money(bal)} of the ` +
|
|
||||||
`bank's money${limit > 0 ? `, ${((-bal * 100) / limit).toFixed(1)}% of the ${money(limit)} sanctioned limit` : ''}.`
|
|
||||||
);
|
|
||||||
} else if (bal > 0) {
|
|
||||||
lines.push(
|
|
||||||
`The account now stands at ${money(bal)} Cr: the customer's own money is in it and none of the limit is in use.`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
lines.push('The account now stands at nil.');
|
|
||||||
}
|
|
||||||
if (s.lowestBalance != null && Number(s.lowestBalance) < 0) {
|
|
||||||
lines.push(
|
|
||||||
`At its lowest it was ${money(s.lowestBalance)} Dr${s.peakLimitUsedPercent != null ? `, which is ${s.peakLimitUsedPercent}% of the limit` : ''}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const mode = t.byMode?.[0];
|
|
||||||
if (mode) {
|
|
||||||
lines.push(`Most of the money moved by ${mode.label} (${mode.sharePercent}% of everything).`);
|
|
||||||
}
|
|
||||||
const party = (t.byCounterparty || []).find((p: any) => p.label !== 'Not recorded');
|
|
||||||
if (party) {
|
|
||||||
lines.push(
|
|
||||||
`The party handling the most money was ${party.label}${party.sub ? ' (' + party.sub + ')' : ''}: ` +
|
|
||||||
`${money(party.in)} Cr from it and ${money(party.out)} Dr to it.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An amount with its sign in front of the rupee sign: -₹1,00,000.00, not ₹-1,00,000.00. */
|
|
||||||
rs(value: number | string | null | undefined, digits = '1.2-2'): string {
|
|
||||||
if (value === null || value === undefined || value === '') return '—';
|
|
||||||
const n = Number(value);
|
|
||||||
return `${n < 0 ? '-' : ''}₹${formatNumber(Math.abs(n), 'en-US', digits)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when a side of the account's activity is well beyond what the customer declared for the window. */
|
/** True when a side of the account's activity is well beyond what the customer declared for the window. */
|
||||||
isSpike(ratio: number | null): boolean {
|
isSpike(ratio: number | null): boolean {
|
||||||
return ratio != null && ratio >= this.spikeMultiple;
|
return ratio != null && ratio >= this.spikeMultiple;
|
||||||
|
|||||||
@@ -58,22 +58,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- A newly added rule is on trial, listed first, until an officer marks it normal -->
|
|
||||||
<div class="period-filters">
|
|
||||||
<span class="period-filters-label">Status</span>
|
|
||||||
<button
|
|
||||||
*ngFor="let s of statusFilters"
|
|
||||||
type="button"
|
|
||||||
class="period-chip"
|
|
||||||
[class.active]="activeStatus === s.key"
|
|
||||||
[disabled]="statusCount(s.key) === 0 && activeStatus !== s.key"
|
|
||||||
(click)="setStatus(s.key)"
|
|
||||||
>
|
|
||||||
{{ s.label }}
|
|
||||||
<span class="period-chip-n">{{ statusCount(s.key) }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Separate the rules by the kind of account they watch -->
|
<!-- Separate the rules by the kind of account they watch -->
|
||||||
<div class="period-filters">
|
<div class="period-filters">
|
||||||
<span class="period-filters-label">Account</span>
|
<span class="period-filters-label">Account</span>
|
||||||
@@ -116,15 +100,7 @@
|
|||||||
<!-- Rule Name Column -->
|
<!-- Rule Name Column -->
|
||||||
<ng-container matColumnDef="ruleName">
|
<ng-container matColumnDef="ruleName">
|
||||||
<th mat-header-cell *matHeaderCellDef>Rule Description</th>
|
<th mat-header-cell *matHeaderCellDef>Rule Description</th>
|
||||||
<td mat-cell *matCellDef="let element" class="rule-name-cell">
|
<td mat-cell *matCellDef="let element" class="rule-name-cell">{{ element.ruleName }}</td>
|
||||||
{{ element.ruleName }}
|
|
||||||
<span
|
|
||||||
*ngIf="element.onTrial"
|
|
||||||
class="trial-badge"
|
|
||||||
[title]="'On trial since ' + (element.trialStartedOn | date: 'dd MMM yyyy')"
|
|
||||||
>TRIAL · day {{ trialDays(element) + 1 }}</span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Risk Category Column -->
|
<!-- Risk Category Column -->
|
||||||
@@ -203,24 +179,8 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Trial: a newly added rule is on trial until an officer marks it normal -->
|
|
||||||
<ng-container matColumnDef="trial">
|
|
||||||
<th mat-header-cell *matHeaderCellDef class="col-center">Trial</th>
|
|
||||||
<td mat-cell *matCellDef="let element" class="col-center">
|
|
||||||
<button *ngIf="element.onTrial" mat-stroked-button type="button" (click)="markNormal(element)">
|
|
||||||
Mark as normal
|
|
||||||
</button>
|
|
||||||
<span *ngIf="!element.onTrial" class="trial-normal">Normal</span>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
<tr
|
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="rule-row"></tr>
|
||||||
mat-row
|
|
||||||
*matRowDef="let row; columns: displayedColumns"
|
|
||||||
class="rule-row"
|
|
||||||
[class.rule-row--trial]="row.onTrial"
|
|
||||||
></tr>
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Mat Paginator for standard pagination -->
|
<!-- Mat Paginator for standard pagination -->
|
||||||
|
|||||||
@@ -78,7 +78,6 @@
|
|||||||
|
|
||||||
.aml-card-content {
|
.aml-card-content {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rules Master / Flagged Transactions. Padding lives on the body rather than the group so the
|
/* Rules Master / Flagged Transactions. Padding lives on the body rather than the group so the
|
||||||
@@ -108,6 +107,11 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line no-duplicate-selectors */
|
||||||
|
.aml-card-content {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.aml-table {
|
.aml-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
@@ -442,26 +446,3 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A rule on trial: listed first, tinted, with a badge, until an officer marks it normal. */
|
|
||||||
.trial-badge {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 8px;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule-row--trial {
|
|
||||||
background: #fffbeb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trial-normal {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -70,8 +70,7 @@ export class AmlRulesComponent implements OnInit {
|
|||||||
'appliesOver',
|
'appliesOver',
|
||||||
'reason',
|
'reason',
|
||||||
'amlCheckStatus',
|
'amlCheckStatus',
|
||||||
'bannedEntityMatch',
|
'bannedEntityMatch'
|
||||||
'trial'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Every rule as returned by the API, before the period filter is applied. */
|
/** Every rule as returned by the API, before the period filter is applied. */
|
||||||
@@ -98,14 +97,6 @@ export class AmlRulesComponent implements OnInit {
|
|||||||
{ key: 'CC', label: 'CC' }
|
{ key: 'CC', label: 'CC' }
|
||||||
];
|
];
|
||||||
activeAccount = '';
|
activeAccount = '';
|
||||||
|
|
||||||
/** Whether to list only the rules still on trial. */
|
|
||||||
readonly statusFilters: { key: string; label: string }[] = [
|
|
||||||
{ key: '', label: 'All rules' },
|
|
||||||
{ key: 'TRIAL', label: 'On trial' }
|
|
||||||
];
|
|
||||||
activeStatus = '';
|
|
||||||
private statusCounts: Record<string, number> = {};
|
|
||||||
private accountCounts: Record<string, number> = {};
|
private accountCounts: Record<string, number> = {};
|
||||||
|
|
||||||
/** Free-text search, applied on top of the period filter. */
|
/** Free-text search, applied on top of the period filter. */
|
||||||
@@ -131,15 +122,7 @@ export class AmlRulesComponent implements OnInit {
|
|||||||
private loadRules(): void {
|
private loadRules(): void {
|
||||||
this.clientsService.getClientRiskRules().subscribe({
|
this.clientsService.getClientRiskRules().subscribe({
|
||||||
next: (data) => {
|
next: (data) => {
|
||||||
// A rule on trial is listed first, the newest first; the rest keep the catalog order.
|
this.allRules = data || [];
|
||||||
const all: any[] = data || [];
|
|
||||||
const trial = all
|
|
||||||
.filter((r) => r.onTrial)
|
|
||||||
.sort((a, b) => String(b.trialStartedOn || '').localeCompare(String(a.trialStartedOn || '')) || b.id - a.id);
|
|
||||||
this.allRules = [
|
|
||||||
...trial,
|
|
||||||
...all.filter((r) => !r.onTrial)
|
|
||||||
];
|
|
||||||
this.applyPeriodFilter();
|
this.applyPeriodFilter();
|
||||||
this.rules.paginator = this.paginator;
|
this.rules.paginator = this.paginator;
|
||||||
},
|
},
|
||||||
@@ -176,33 +159,6 @@ export class AmlRulesComponent implements OnInit {
|
|||||||
this.applyPeriodFilter();
|
this.applyPeriodFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(key: string): void {
|
|
||||||
this.activeStatus = key;
|
|
||||||
this.applyPeriodFilter();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** How many rules are on each status chip — shown on the chips. */
|
|
||||||
statusCount(key: string): number {
|
|
||||||
return this.statusCounts[key] ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ends a rule's trial: it stays in the catalog as a normal rule. */
|
|
||||||
markNormal(rule: any): void {
|
|
||||||
this.clientsService.markClientRiskRuleNormal(rule.id).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.alertService.alert({ type: 'AML Rule', message: `"${rule.ruleName}" is now a normal rule.` });
|
|
||||||
this.loadRules();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Days a rule has been on trial, from the date it was added. */
|
|
||||||
trialDays(rule: any): number {
|
|
||||||
if (!rule?.trialStartedOn) return 0;
|
|
||||||
const started = new Date(String(rule.trialStartedOn));
|
|
||||||
return Math.max(0, Math.floor((Date.now() - started.getTime()) / 86400000));
|
|
||||||
}
|
|
||||||
|
|
||||||
setAccount(key: string): void {
|
setAccount(key: string): void {
|
||||||
this.activeAccount = key;
|
this.activeAccount = key;
|
||||||
this.applyPeriodFilter();
|
this.applyPeriodFilter();
|
||||||
@@ -245,10 +201,7 @@ export class AmlRulesComponent implements OnInit {
|
|||||||
|
|
||||||
private applyPeriodFilter(): void {
|
private applyPeriodFilter(): void {
|
||||||
this.rules.data = this.allRules.filter(
|
this.rules.data = this.allRules.filter(
|
||||||
(r) =>
|
(r) => this.inAccountScope(r) && (!this.activePeriod || this.periodKey(r) === this.activePeriod)
|
||||||
this.inAccountScope(r) &&
|
|
||||||
(!this.activeStatus || !!r.onTrial) &&
|
|
||||||
(!this.activePeriod || this.periodKey(r) === this.activePeriod)
|
|
||||||
);
|
);
|
||||||
this.recomputePeriodCounts();
|
this.recomputePeriodCounts();
|
||||||
if (this.rules.paginator) {
|
if (this.rules.paginator) {
|
||||||
@@ -271,8 +224,7 @@ export class AmlRulesComponent implements OnInit {
|
|||||||
// The account chips count within the search and the chosen period; the period chips within the chosen account.
|
// The account chips count within the search and the chosen period; the period chips within the chosen account.
|
||||||
const inPeriod = searched.filter((r) => !this.activePeriod || this.periodKey(r) === this.activePeriod);
|
const inPeriod = searched.filter((r) => !this.activePeriod || this.periodKey(r) === this.activePeriod);
|
||||||
this.accountCounts = { '': inPeriod.length, CC: inPeriod.filter((r) => !!r.ccRelevant).length };
|
this.accountCounts = { '': inPeriod.length, CC: inPeriod.filter((r) => !!r.ccRelevant).length };
|
||||||
this.statusCounts = { '': searched.length, TRIAL: searched.filter((r) => !!r.onTrial).length };
|
const pool = searched.filter((r) => this.inAccountScope(r));
|
||||||
const pool = searched.filter((r) => this.inAccountScope(r) && (!this.activeStatus || !!r.onTrial));
|
|
||||||
this.periodCounts = { '': pool.length };
|
this.periodCounts = { '': pool.length };
|
||||||
for (const p of this.periodFilters) {
|
for (const p of this.periodFilters) {
|
||||||
if (p.key) {
|
if (p.key) {
|
||||||
|
|||||||
@@ -399,8 +399,6 @@ $red: #c5221f;
|
|||||||
|
|
||||||
&__table {
|
&__table {
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
// Two more columns now that each ratio states its own rate.
|
|
||||||
min-width: 1100px;
|
|
||||||
|
|
||||||
th {
|
th {
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
@@ -722,6 +720,12 @@ $red: #c5221f;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line no-duplicate-selectors */
|
||||||
|
.f1s-daily__table {
|
||||||
|
// Two more columns now that each ratio states its own rate.
|
||||||
|
min-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
// The rate each day's requirement was struck at.
|
// The rate each day's requirement was struck at.
|
||||||
.f1s-daily__rate {
|
.f1s-daily__rate {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
+2
-2
@@ -9,8 +9,8 @@
|
|||||||
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
import { Component, OnInit, ViewChild, inject } from '@angular/core';
|
||||||
import { ViewJournalEntryComponent } from '../view-journal-entry/view-journal-entry.component';
|
import { ViewJournalEntryComponent } from '../view-journal-entry/view-journal-entry.component';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { AccountingService } from '../../../accounting/accounting.service';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { AccountingService } from 'app/accounting/accounting.service';
|
||||||
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
import { MatSort, MatSortHeader } from '@angular/material/sort';
|
||||||
import { MatPaginator } from '@angular/material/paginator';
|
import { MatPaginator } from '@angular/material/paginator';
|
||||||
import {
|
import {
|
||||||
@@ -66,9 +66,9 @@ import { MatTooltipModule } from '@angular/material/tooltip';
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class ViewJournalEntryTransactionComponent implements OnInit {
|
export class ViewJournalEntryTransactionComponent implements OnInit {
|
||||||
|
private accountingService = inject(AccountingService);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private accountingService = inject(AccountingService);
|
|
||||||
dialog = inject(MatDialog);
|
dialog = inject(MatDialog);
|
||||||
private location = inject(Location);
|
private location = inject(Location);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnDestroy, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpContext, HttpDownloadProgressEvent, HttpEvent, HttpEventType } from '@angular/common/http';
|
||||||
|
import { BehaviorSubject, Subscription } from 'rxjs';
|
||||||
|
import { AuthenticationService } from '../core/authentication/authentication.service';
|
||||||
|
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||||
|
import { NO_PROGRESS_BAR } from '../core/progress-bar/no-progress.token';
|
||||||
|
|
||||||
|
/** What the server pushes: the total and, for each Approval Center tab with something waiting, its number. */
|
||||||
|
interface ApprovalCounts {
|
||||||
|
total: number;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** After a stream drops, the first wait before reconnecting; it doubles up to the cap. */
|
||||||
|
const RETRY_MS = 2_000;
|
||||||
|
const RETRY_MAX_MS = 60_000;
|
||||||
|
|
||||||
|
/** A stream that ends sooner than this after opening is treated as a failure. */
|
||||||
|
const MIN_STREAM_MS = 3_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many requests wait on each Approval Center tab, and the total for the Approvals menu.
|
||||||
|
*
|
||||||
|
* The numbers are pushed, not polled: the service opens one Server-Sent Events stream, GET /approvals/counts/stream, and
|
||||||
|
* the server writes a `counts` event the moment the numbers change, so a request raised or actioned by anyone moves the
|
||||||
|
* badge within about a second with no reloads and no traffic while nothing changes. The server answers numbers only
|
||||||
|
* (no rows); a tab still loads its own list when it is opened, and applies its own table, status, office scope and
|
||||||
|
* permission, so a tab the login may not see is absent.
|
||||||
|
*
|
||||||
|
* The stream goes through HttpClient (not EventSource, which cannot send the login's headers), so the usual
|
||||||
|
* authentication and tenant headers apply. It is silent (see SILENT_HTTP_ERROR, NO_PROGRESS_BAR): no error popup and no
|
||||||
|
* progress bar. The server ends a stream every few minutes and the service reconnects at once; after a failure it keeps
|
||||||
|
* the last numbers and retries with a growing wait.
|
||||||
|
*
|
||||||
|
* A browser tab that is hidden keeps its stream: it is one idle connection, and the badge is right when it is shown.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ApprovalCountsService implements OnDestroy {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private authenticationService = inject(AuthenticationService);
|
||||||
|
|
||||||
|
private readonly countsSubject = new BehaviorSubject<Record<string, number>>({});
|
||||||
|
private readonly totalSubject = new BehaviorSubject<number>(0);
|
||||||
|
/** Pending count per tab key; a tab with nothing waiting is absent. */
|
||||||
|
readonly counts$ = this.countsSubject.asObservable();
|
||||||
|
/** Everything waiting across all the tabs the signed-in user can see. */
|
||||||
|
readonly total$ = this.totalSubject.asObservable();
|
||||||
|
|
||||||
|
private active = false;
|
||||||
|
private failures = 0;
|
||||||
|
private stream: Subscription | null = null;
|
||||||
|
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/** Whether this login reaches the Approval Center at all, so nothing is asked for everyone else. */
|
||||||
|
get canSeeApprovals(): boolean {
|
||||||
|
const permissions = this.authenticationService.getCredentials()?.permissions ?? [];
|
||||||
|
return permissions.includes('ALL_FUNCTIONS') || permissions.includes('MENU_TASKS');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the stream (once; a second call changes nothing). */
|
||||||
|
start(): void {
|
||||||
|
if (this.active || !this.canSeeApprovals) return;
|
||||||
|
this.active = true;
|
||||||
|
// After a page refresh the last numbers show at once; the stream's first event (a moment later) makes them exact.
|
||||||
|
this.restore();
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.active = false;
|
||||||
|
this.stream?.unsubscribe();
|
||||||
|
this.stream = null;
|
||||||
|
if (this.retryTimer) clearTimeout(this.retryTimer);
|
||||||
|
this.retryTimer = null;
|
||||||
|
this.failures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private connect(): void {
|
||||||
|
if (!this.active) return;
|
||||||
|
// Text already handled, so each progress event is only read from where the last one ended.
|
||||||
|
let consumed = 0;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
this.stream = this.http
|
||||||
|
.get('/approvals/counts/stream', {
|
||||||
|
observe: 'events',
|
||||||
|
reportProgress: true,
|
||||||
|
responseType: 'text',
|
||||||
|
headers: { Accept: 'text/event-stream' },
|
||||||
|
context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true)
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (event: HttpEvent<string>) => {
|
||||||
|
if (event.type !== HttpEventType.DownloadProgress) return;
|
||||||
|
const text = (event as HttpDownloadProgressEvent).partialText ?? '';
|
||||||
|
consumed = this.read(text, consumed);
|
||||||
|
},
|
||||||
|
// The server closes a stream after a few minutes: connect again straight away. One that ended almost at once
|
||||||
|
// was not a stream at all (a proxy answering for it, say), so that is a failure and backs off.
|
||||||
|
complete: () => (Date.now() - startedAt < MIN_STREAM_MS ? this.retryLater() : this.connect()),
|
||||||
|
error: () => this.retryLater()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private retryLater(): void {
|
||||||
|
this.failures += 1;
|
||||||
|
// While the stream is down (an older server without it, a proxy that blocks it) the badge is still kept right by a
|
||||||
|
// plain read each time the retry comes round; the stream takes over again as soon as it connects.
|
||||||
|
this.http
|
||||||
|
.get<ApprovalCounts>('/approvals/counts', {
|
||||||
|
params: { fresh: 'true' },
|
||||||
|
context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true)
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (answer) => {
|
||||||
|
this.countsSubject.next(answer.counts ?? {});
|
||||||
|
this.totalSubject.next(answer.total ?? 0);
|
||||||
|
this.remember(answer);
|
||||||
|
},
|
||||||
|
error: () => undefined
|
||||||
|
});
|
||||||
|
const wait = Math.min(RETRY_MAX_MS, RETRY_MS * 2 ** (this.failures - 1));
|
||||||
|
this.retryTimer = setTimeout(() => this.connect(), wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads every complete event after `from` (events end with a blank line) and returns how far it got. */
|
||||||
|
private read(text: string, from: number): number {
|
||||||
|
let position = from;
|
||||||
|
let end = text.indexOf('\n\n', position);
|
||||||
|
while (end !== -1) {
|
||||||
|
this.handle(text.slice(position, end));
|
||||||
|
position = end + 2;
|
||||||
|
end = text.indexOf('\n\n', position);
|
||||||
|
}
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handle(block: string): void {
|
||||||
|
let name = 'message';
|
||||||
|
const data: string[] = [];
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
if (line.startsWith('event:')) name = line.slice(6).trim();
|
||||||
|
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
|
||||||
|
}
|
||||||
|
// Comment lines (the server's keep-alive pings) carry no event and are ignored.
|
||||||
|
if (name !== 'counts' || data.length === 0) return;
|
||||||
|
try {
|
||||||
|
const answer = JSON.parse(data.join('\n')) as ApprovalCounts;
|
||||||
|
this.failures = 0;
|
||||||
|
this.countsSubject.next(answer.counts ?? {});
|
||||||
|
this.totalSubject.next(answer.total ?? 0);
|
||||||
|
this.remember(answer);
|
||||||
|
} catch {
|
||||||
|
// A damaged event is skipped; the next change sends the numbers again.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storage is per login, so one user's numbers are never shown to the next person on the same browser. */
|
||||||
|
private storageKey(): string {
|
||||||
|
return `approvalCounts:${this.authenticationService.getCredentials()?.username ?? ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private remember(answer: ApprovalCounts): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(this.storageKey(), JSON.stringify({ total: answer.total, counts: answer.counts }));
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked or full; the badge simply starts empty after a refresh.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private restore(): void {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem(this.storageKey()) ?? 'null') as ApprovalCounts | null;
|
||||||
|
if (saved && typeof saved.total === 'number' && saved.counts) {
|
||||||
|
this.countsSubject.next(saved.counts);
|
||||||
|
this.totalSubject.next(saved.total);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Unreadable or missing: start empty and wait for the first event.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="inbox" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Checker Inbox</span>
|
<span>Checker Inbox</span>
|
||||||
|
@if (count('checker-inbox')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('checker-inbox') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -40,10 +43,16 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="user-check" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Client Approval</span>
|
<span>Client Approval</span>
|
||||||
|
@if (count('client-approval')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('client-approval') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'">
|
<a class="ac-tab" routerLink="./loan-approval" routerLinkActive="ac-tab--active" *mifosxHasPermission="'READ_LOAN'">
|
||||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Loan Approval</span>
|
<span>Loan Approval</span>
|
||||||
|
@if (count('loan-approval')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-approval') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -53,6 +62,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Loan Disbursal</span>
|
<span>Loan Disbursal</span>
|
||||||
|
@if (count('loan-disbursal')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('loan-disbursal') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -62,6 +74,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="calendar-alt" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Reschedule Loan</span>
|
<span>Reschedule Loan</span>
|
||||||
|
@if (count('reschedule-loan')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('reschedule-loan') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -71,6 +86,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Account</span>
|
<span>Pending Account</span>
|
||||||
|
@if (count('pending-account')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-account') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -80,6 +98,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Share Accounts</span>
|
<span>Pending Share Accounts</span>
|
||||||
|
@if (count('pending-share-accounts')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-share-accounts') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -89,6 +110,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Product Approval</span>
|
<span>Product Approval</span>
|
||||||
|
@if (count('product-approval')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('product-approval') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -98,6 +122,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Account Edits</span>
|
<span>Pending Account Edits</span>
|
||||||
|
@if (count('pending-savings-account-edits')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-savings-account-edits') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -107,6 +134,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="edit" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending Client Edits</span>
|
<span>Pending Client Edits</span>
|
||||||
|
@if (count('pending-client-edits')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-client-edits') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -116,6 +146,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="user-cog" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="user-cog" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Pending User Approvals</span>
|
<span>Pending User Approvals</span>
|
||||||
|
@if (count('pending-user-edits')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('pending-user-edits') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -143,6 +176,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Withdrawal Requests</span>
|
<span>Withdrawal Requests</span>
|
||||||
|
@if (count('withdrawal-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('withdrawal-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -152,6 +188,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="exchange" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="exchange" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Cash Transfers</span>
|
<span>Cash Transfers</span>
|
||||||
|
@if (count('cash-transfers')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('cash-transfers') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -161,6 +200,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="undo" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="undo" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Voucher Corrections</span>
|
<span>Voucher Corrections</span>
|
||||||
|
@if (count('voucher-corrections')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('voucher-corrections') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -170,6 +212,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Share Withdrawal Requests</span>
|
<span>Share Withdrawal Requests</span>
|
||||||
|
@if (count('share-withdrawal-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('share-withdrawal-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -179,6 +224,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Dividend Requests</span>
|
<span>Dividend Requests</span>
|
||||||
|
@if (count('dividend-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('dividend-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -188,6 +236,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="money-check-alt" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-check-alt" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Share Addition Requests</span>
|
<span>Share Addition Requests</span>
|
||||||
|
@if (count('share-addition-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('share-addition-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -197,6 +248,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="hand-holding-usd" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Deposit Requests</span>
|
<span>Deposit Requests</span>
|
||||||
|
@if (count('deposit-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('deposit-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -206,10 +260,16 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-bill-wave" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Disbursement & Transfers</span>
|
<span>Disbursement & Transfers</span>
|
||||||
|
@if (count('disbursement-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('disbursement-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a class="ac-tab" routerLink="./dds-approvals" routerLinkActive="ac-tab--active">
|
<a class="ac-tab" routerLink="./dds-approvals" routerLinkActive="ac-tab--active">
|
||||||
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="coins" class="ac-tab__ic"></fa-icon>
|
||||||
<span>DDS Approvals</span>
|
<span>DDS Approvals</span>
|
||||||
|
@if (count('dds-approvals')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('dds-approvals') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -219,6 +279,9 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="money-check" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Cheque Book Requests</span>
|
<span>Cheque Book Requests</span>
|
||||||
|
@if (count('chequebook-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('chequebook-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="ac-tab"
|
class="ac-tab"
|
||||||
@@ -228,26 +291,41 @@
|
|||||||
>
|
>
|
||||||
<fa-icon icon="lock" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="lock" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Closure Requests</span>
|
<span>Closure Requests</span>
|
||||||
|
@if (count('closure-requests')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('closure-requests') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a class="ac-tab" routerLink="./dd-issue-approvals" routerLinkActive="ac-tab--active">
|
<a class="ac-tab" routerLink="./dd-issue-approvals" routerLinkActive="ac-tab--active">
|
||||||
<fa-icon icon="receipt" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="receipt" class="ac-tab__ic"></fa-icon>
|
||||||
<span>DD Approvals</span>
|
<span>DD Approvals</span>
|
||||||
|
@if (count('dd-issue-approvals')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-issue-approvals') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a class="ac-tab" routerLink="./dd-reprint-approvals" routerLinkActive="ac-tab--active">
|
<a class="ac-tab" routerLink="./dd-reprint-approvals" routerLinkActive="ac-tab--active">
|
||||||
<fa-icon icon="print" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="print" class="ac-tab__ic"></fa-icon>
|
||||||
<span>DD Reprint Requests</span>
|
<span>DD Reprint Requests</span>
|
||||||
|
@if (count('dd-reprint-approvals')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-reprint-approvals') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<!-- Beside the reprint queue because the two arrive together from the counter, but they are
|
<!-- Beside the reprint queue because the two arrive together from the counter, but they are
|
||||||
different decisions: a reprint re-prints the same leaflet, a revoke spends a fresh one. -->
|
different decisions: a reprint re-prints the same leaflet, a revoke spends a fresh one. -->
|
||||||
<a class="ac-tab" routerLink="./dd-revoke-approvals" routerLinkActive="ac-tab--active">
|
<a class="ac-tab" routerLink="./dd-revoke-approvals" routerLinkActive="ac-tab--active">
|
||||||
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
|
||||||
<span>DD Revoke Requests</span>
|
<span>DD Revoke Requests</span>
|
||||||
|
@if (count('dd-revoke-approvals')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('dd-revoke-approvals') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<!-- Raised from the Risk Review Report. Approving moves the customer's KYC rating and their
|
<!-- Raised from the Risk Review Report. Approving moves the customer's KYC rating and their
|
||||||
re-KYC due date together, so it belongs with the other maker-checker queues. -->
|
re-KYC due date together, so it belongs with the other maker-checker queues. -->
|
||||||
<a class="ac-tab" routerLink="./risk-change-approvals" routerLinkActive="ac-tab--active">
|
<a class="ac-tab" routerLink="./risk-change-approvals" routerLinkActive="ac-tab--active">
|
||||||
<fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon>
|
<fa-icon icon="shield-alt" class="ac-tab__ic"></fa-icon>
|
||||||
<span>Risk Category Changes</span>
|
<span>Risk Category Changes</span>
|
||||||
|
@if (count('risk-change-approvals')) {
|
||||||
|
<span class="ac-tab__count" title="Waiting for action">{{ count('risk-change-approvals') }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -103,11 +103,30 @@ $amber: #f59e0b;
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How many requests wait on the tab.
|
||||||
|
&__count {
|
||||||
|
min-width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 17px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover:not(.ac-tab--active) {
|
&:hover:not(.ac-tab--active) {
|
||||||
background: #f1f5f9;
|
background: #f1f5f9;
|
||||||
color: $navy;
|
color: $navy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.ac-tab--active .ac-tab__count {
|
||||||
|
background: #fff;
|
||||||
|
color: $blue-d;
|
||||||
|
}
|
||||||
|
|
||||||
&.ac-tab--active {
|
&.ac-tab--active {
|
||||||
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
background: linear-gradient(135deg, $blue 0%, $blue-d 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user