Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e98077158 | ||
|
|
731ab5a0e0 | ||
|
|
7bd880387e | ||
|
|
86e3a5da27 | ||
|
|
d89f13471c | ||
|
|
357f666421 | ||
|
|
3e5cc26e49 | ||
|
|
6890584b16 | ||
|
|
c3713f8a90 | ||
|
|
f804d356d7 | ||
|
|
1daa8e60a0 | ||
|
|
d584af8e54 | ||
|
|
e1b33827ea | ||
|
|
8ed8f92b00 | ||
|
|
4d205672df | ||
|
|
c9feeae3cb | ||
|
|
0404197c1b | ||
|
|
8a6becff86 | ||
|
|
c803e20628 | ||
|
|
6a04a92113 | ||
|
|
88f34f58e3 | ||
|
|
88c4242692 | ||
|
|
ca01e9b65b | ||
|
|
a4e0a22205 |
@@ -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,
|
||||||
|
|||||||
@@ -386,6 +386,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<!-- Investment interest vouchers that are due but not posted. A reminder only: unlike the interest
|
||||||
|
above it never stops the close (Jitendra Sir); the voucher can still be posted afterwards. -->
|
||||||
|
@if (preview?.investmentInterestPending?.length) {
|
||||||
|
<div class="bd-warn">
|
||||||
|
<mat-icon>notifications_active</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Reminder: investment interest vouchers are pending.</strong>
|
||||||
|
@for (t of preview.investmentInterestPending; track t.purchaseId) {
|
||||||
|
<div>
|
||||||
|
{{ t.lineType === 'COUPON' ? 'Coupon' : 'Provision' }} · {{ t.scriptDesc }} · deal {{ t.dealNo }} ·
|
||||||
|
{{ t.intDate }} · ₹{{ (t.lineType === 'COUPON' ? t.grossInterest : t.amount) | number: '1.2-2' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
Post them under Investment → Govt Security / Bond → 03 Interest Entry. You can still close the day.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (preview?.investmentFdPending?.length) {
|
||||||
|
<div class="bd-warn">
|
||||||
|
<mat-icon>notifications_active</mat-icon>
|
||||||
|
<div>
|
||||||
|
<strong>Reminder: FD investment entries are pending.</strong>
|
||||||
|
@for (t of preview.investmentFdPending; track t.fdId) {
|
||||||
|
<div>
|
||||||
|
{{ t.lineType === 'MATURITY' ? 'Maturity' : t.lineType === 'PAYOUT' ? 'Interest payout' : 'Provision' }}
|
||||||
|
· {{ t.partyName }} · FD {{ t.dealNo }} · {{ t.intDate }} · ₹{{ t.amount | number: '1.2-2' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
Post them under Investment → FD Investment. You can still close the day.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (preview?.savingsInterestPending?.length) {
|
@if (preview?.savingsInterestPending?.length) {
|
||||||
<div class="bd-warn">
|
<div class="bd-warn">
|
||||||
<mat-icon>warning</mat-icon>
|
<mat-icon>warning</mat-icon>
|
||||||
|
|||||||
@@ -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(
|
||||||
([
|
([
|
||||||
|
|||||||
@@ -109,7 +109,15 @@ export class ErrorHandlerInterceptor implements HttpInterceptor {
|
|||||||
} else if (status === 400) {
|
} else if (status === 400) {
|
||||||
alert = { type: 'Bad Request', message: errorMessage || 'Invalid parameters were passed in the request!' };
|
alert = { type: 'Bad Request', message: errorMessage || 'Invalid parameters were passed in the request!' };
|
||||||
} else if (status === 403) {
|
} else if (status === 403) {
|
||||||
alert = { type: 'Unauthorized Request', message: errorMessage || 'You are not authorized for this request!' };
|
// A 403 is either a missing permission or a business rule the record does not meet yet ("post the coupon
|
||||||
|
// first", "matures on …"). Only the first is an authorisation problem; the rule is told by its code.
|
||||||
|
const errorCode: string =
|
||||||
|
errorBody?.errors?.[0]?.userMessageGlobalisationCode || errorBody?.userMessageGlobalisationCode || '';
|
||||||
|
const isPermission = !errorCode || /not\.authori[sz]ed|unauthori[sz]ed|access\.denied|forbidden/i.test(errorCode);
|
||||||
|
alert = {
|
||||||
|
type: isPermission ? 'Unauthorized Request' : 'Action Not Allowed',
|
||||||
|
message: errorMessage || 'You are not authorized for this request!'
|
||||||
|
};
|
||||||
} else if (status === 404) {
|
} else if (status === 404) {
|
||||||
alert = {
|
alert = {
|
||||||
type: this.translate.instant('error.resource.not.found'),
|
type: this.translate.instant('error.resource.not.found'),
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1539,13 +1522,13 @@
|
|||||||
<!-- Investment -->
|
<!-- Investment -->
|
||||||
<div class="deposit-menu-group">
|
<div class="deposit-menu-group">
|
||||||
<div class="deposit-group-title">Investment</div>
|
<div class="deposit-group-title">Investment</div>
|
||||||
<button mat-menu-item routerLink="/investment/dashboard" tabindex="0">
|
<button mat-menu-item routerLink="/investment/diary" tabindex="0">
|
||||||
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
|
<fa-icon icon="calendar-alt" class="menu-item-icon"></fa-icon> Due Date Diary for All Investment
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
|
<button mat-menu-item routerLink="/investment/interest-report" tabindex="0">
|
||||||
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received & Receivable Report
|
<fa-icon icon="dollar-sign" class="menu-item-icon"></fa-icon> Total Int Received & Receivable Report
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/investment/reports" tabindex="0">
|
<button mat-menu-item routerLink="/investment/bucket-report" tabindex="0">
|
||||||
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
|
<fa-icon icon="piggy-bank" class="menu-item-icon"></fa-icon> Bucket Wise Investment
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item routerLink="/deaf" tabindex="0">
|
<button mat-menu-item routerLink="/deaf" tabindex="0">
|
||||||
@@ -2409,7 +2392,7 @@
|
|||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesInvestmentSearch('Due Date Diary for All Investment')"
|
[class.hidden]="!matchesInvestmentSearch('Due Date Diary for All Investment')"
|
||||||
routerLink="/investment/dashboard"
|
routerLink="/investment/diary"
|
||||||
(click)="investmentMenuOpen = false"
|
(click)="investmentMenuOpen = false"
|
||||||
>
|
>
|
||||||
<fa-icon icon="calendar-check" class="gl-icon"></fa-icon> Due Date Diary for All Investment
|
<fa-icon icon="calendar-check" class="gl-icon"></fa-icon> Due Date Diary for All Investment
|
||||||
@@ -2417,7 +2400,7 @@
|
|||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesInvestmentSearch('Bucket Wise Investment')"
|
[class.hidden]="!matchesInvestmentSearch('Bucket Wise Investment')"
|
||||||
routerLink="/investment/reports"
|
routerLink="/investment/bucket-report"
|
||||||
(click)="investmentMenuOpen = false"
|
(click)="investmentMenuOpen = false"
|
||||||
>
|
>
|
||||||
<fa-icon icon="layer-group" class="gl-icon"></fa-icon> Bucket Wise Investment
|
<fa-icon icon="layer-group" class="gl-icon"></fa-icon> Bucket Wise Investment
|
||||||
@@ -2517,7 +2500,7 @@
|
|||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesInvestmentSearch('Total Int Received & Receivable Report')"
|
[class.hidden]="!matchesInvestmentSearch('Total Int Received & Receivable Report')"
|
||||||
routerLink="/investment/reports"
|
routerLink="/investment/interest-report"
|
||||||
(click)="investmentMenuOpen = false"
|
(click)="investmentMenuOpen = false"
|
||||||
>
|
>
|
||||||
<fa-icon icon="hand-holding-usd" class="gl-icon"></fa-icon> Total Int Received & Receivable Report
|
<fa-icon icon="hand-holding-usd" class="gl-icon"></fa-icon> Total Int Received & Receivable Report
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
@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>
|
||||||
</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;
|
||||||
|
}
|
||||||
|
|||||||
+139
-78
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-4
@@ -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>
|
||||||
|
@if (!isPreview) {
|
||||||
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
|
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -37,9 +52,13 @@
|
|||||||
<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>
|
||||||
|
@if (!isPreview) {
|
||||||
<td class="isd-table__status-col">
|
<td class="isd-table__status-col">
|
||||||
@if (period.paid) {
|
@if (period.paid) {
|
||||||
<span class="isd-badge isd-badge--paid">
|
<span class="isd-badge isd-badge--paid">
|
||||||
@@ -49,16 +68,19 @@
|
|||||||
<span class="isd-badge isd-badge--pending">Pending</span>
|
<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>
|
||||||
|
@if (!isPreview) {
|
||||||
<td></td>
|
<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);
|
||||||
|
|||||||
@@ -47,6 +47,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending investment entries (GOI / bond interest, FDs with other banks): one line each, details on demand -->
|
||||||
|
@if (pendingInvestmentInterest.length || pendingFd.length) {
|
||||||
|
<div class="investment-interest-alert">
|
||||||
|
<span class="iia-icon">🔔</span>
|
||||||
|
<div class="iia-text">
|
||||||
|
<strong>Investment entries pending</strong>
|
||||||
|
@if (pendingInvestmentInterest.length) {
|
||||||
|
<div class="iia-row">
|
||||||
|
<span class="iia-summary"
|
||||||
|
>GOI / Bonds: {{ pendingInvestmentInterest.length }} interest voucher(s){{
|
||||||
|
overdueNote(pendingInvestmentInterest)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
<button type="button" class="iia-toggle" (click)="showInterestDetails = !showInterestDetails">
|
||||||
|
{{ showInterestDetails ? 'Hide' : 'Details' }}
|
||||||
|
</button>
|
||||||
|
<a class="iia-action" routerLink="/investment/bonds" [queryParams]="{ tab: 'interest' }"
|
||||||
|
>Open Interest Entry →</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
@if (showInterestDetails) {
|
||||||
|
@for (t of pendingInvestmentInterest.slice(0, detailLimit); track t.purchaseId) {
|
||||||
|
<a class="iia-item" routerLink="/investment/bonds" [queryParams]="{ tab: 'interest' }">
|
||||||
|
{{ t.lineType === 'COUPON' ? 'Coupon' : 'Provision' }} · {{ t.scriptDesc }} · deal {{ t.dealNo }} ·
|
||||||
|
{{ t.overdue ? 'overdue since ' + t.intDate : t.postable ? 'due today' : 'due ' + t.intDate }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (pendingInvestmentInterest.length > detailLimit) {
|
||||||
|
<span class="iia-more">+{{ pendingInvestmentInterest.length - detailLimit }} more in Interest Entry</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (pendingFd.length) {
|
||||||
|
<div class="iia-row">
|
||||||
|
<span class="iia-summary"
|
||||||
|
>FD with banks: {{ pendingFd.length }} entry(ies){{ overdueNote(pendingFd) }}</span
|
||||||
|
>
|
||||||
|
<button type="button" class="iia-toggle" (click)="showFdDetails = !showFdDetails">
|
||||||
|
{{ showFdDetails ? 'Hide' : 'Details' }}
|
||||||
|
</button>
|
||||||
|
<a class="iia-action" routerLink="/investment/fd">Open FD Investment →</a>
|
||||||
|
</div>
|
||||||
|
@if (showFdDetails) {
|
||||||
|
@for (t of pendingFd.slice(0, detailLimit); track t.fdId + t.lineType + t.intDate) {
|
||||||
|
<a class="iia-item" routerLink="/investment/fd" [queryParams]="{ tab: 'interest', fd: t.fdId }">
|
||||||
|
{{ t.lineType === 'MATURITY' ? 'Maturity' : t.lineType === 'PAYOUT' ? 'Interest payout' : 'Provision' }}
|
||||||
|
· {{ t.partyName }} · FD {{ t.dealNo }} ·
|
||||||
|
{{ t.overdue ? 'overdue since ' + t.intDate : t.postable ? 'due today' : 'due ' + t.intDate }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (pendingFd.length > detailLimit) {
|
||||||
|
<span class="iia-more">+{{ pendingFd.length - detailLimit }} more in FD Investment</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Quick Action Cards -->
|
<!-- Quick Action Cards -->
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<a class="qa-card" [routerLink]="['/dashboard']" #buttonDashboard>
|
<a class="qa-card" [routerLink]="['/dashboard']" #buttonDashboard>
|
||||||
|
|||||||
@@ -424,3 +424,78 @@ $accent: #0ea5e9;
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.investment-interest-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #f5c26b;
|
||||||
|
border-left: 4px solid #e08e0b;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff8e6;
|
||||||
|
color: #5c3d00;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
.iia-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iia-text {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iia-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iia-summary {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iia-toggle {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border: 1px solid #e0a43a;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iia-item {
|
||||||
|
padding-left: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iia-more {
|
||||||
|
padding-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.iia-item:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iia-action {
|
||||||
|
align-self: center;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { PopoverService } from '../configuration-wizard/popover/popover.service'
|
|||||||
import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service';
|
import { ConfigurationWizardService } from '../configuration-wizard/configuration-wizard.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { DscService } from 'app/users/dsc/dsc.service';
|
import { DscService } from 'app/users/dsc/dsc.service';
|
||||||
|
import { InvestmentFdDueDto, InvestmentInterestDueDto, InvestmentService } from 'app/investment/investment.service';
|
||||||
|
|
||||||
/** Custom Components */
|
/** Custom Components */
|
||||||
import { NextStepDialogComponent } from '../configuration-wizard/next-step-dialog/next-step-dialog.component';
|
import { NextStepDialogComponent } from '../configuration-wizard/next-step-dialog/next-step-dialog.component';
|
||||||
@@ -109,6 +110,7 @@ export class HomeComponent implements OnInit, AfterViewInit {
|
|||||||
this.tenant = this.tenantIdentifier();
|
this.tenant = this.tenantIdentifier();
|
||||||
this.setFilteredActivities();
|
this.setFilteredActivities();
|
||||||
this.loadDscStatus();
|
this.loadDscStatus();
|
||||||
|
this.loadPendingInvestmentInterest();
|
||||||
if (!this.authenticationService.hasDialogBeenShown()) {
|
if (!this.authenticationService.hasDialogBeenShown()) {
|
||||||
this.dialog.open(WarningDialogComponent, {
|
this.dialog.open(WarningDialogComponent, {
|
||||||
disableClose: false,
|
disableClose: false,
|
||||||
@@ -118,6 +120,39 @@ export class HomeComponent implements OnInit, AfterViewInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Investment interest lines due now or within two days, shown on the home screen so nobody has to look for them. */
|
||||||
|
pendingInvestmentInterest: InvestmentInterestDueDto[] = [];
|
||||||
|
/** FDs placed with other banks: interest vouchers and maturities due now or within two days. */
|
||||||
|
pendingFd: InvestmentFdDueDto[] = [];
|
||||||
|
/** The lists stay folded to one line each; the user opens them when needed. */
|
||||||
|
showInterestDetails = false;
|
||||||
|
showFdDetails = false;
|
||||||
|
readonly detailLimit = 5;
|
||||||
|
private readonly investmentService = inject(InvestmentService);
|
||||||
|
|
||||||
|
/** " · 3 overdue, oldest 14-08-2025" for a list of due entries (dates DD-MM-YYYY); empty when none is overdue. */
|
||||||
|
overdueNote(items: Array<{ overdue?: boolean; intDate: string }>): string {
|
||||||
|
const late = items.filter((t) => t.overdue);
|
||||||
|
if (!late.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const key = (d: string) => d.split('-').reverse().join('');
|
||||||
|
const oldest = late.map((t) => t.intDate).sort((a, b) => key(a).localeCompare(key(b)))[0];
|
||||||
|
return ` · ${late.length} overdue, oldest ${oldest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Silent when the user cannot see investments or nothing is due — the strip simply does not render. */
|
||||||
|
private loadPendingInvestmentInterest(): void {
|
||||||
|
this.investmentService.getDueInterest().subscribe({
|
||||||
|
next: (due) => (this.pendingInvestmentInterest = due ?? []),
|
||||||
|
error: () => (this.pendingInvestmentInterest = [])
|
||||||
|
});
|
||||||
|
this.investmentService.getFdDue().subscribe({
|
||||||
|
next: (due) => (this.pendingFd = due ?? []),
|
||||||
|
error: () => (this.pendingFd = [])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Surface a small "DSC verified" badge in the welcome banner when the
|
* Surface a small "DSC verified" badge in the welcome banner when the
|
||||||
* signed-in user has at least one ACTIVE DSC certificate. Silent on
|
* signed-in user has at least one ACTIVE DSC certificate. Silent on
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
|||||||
|
<!--
|
||||||
|
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">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
|
||||||
|
<h1 class="pg-title">Bucket Wise Investment</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Govt Securities, bonds and FDs held on a date, split by the time left to maturity. Click a bucket to see the
|
||||||
|
holdings in it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="!report || generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Generating…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="inv-form-grid">
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>As on</span><mifosx-date-ddmmyyyy [(ngModel)]="asOnDate"></mifosx-date-ddmmyyyy
|
||||||
|
></label>
|
||||||
|
</div>
|
||||||
|
<div class="diary-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="loading" (click)="load()">
|
||||||
|
{{ loading ? 'Loading…' : 'Show' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (error) {
|
||||||
|
<p class="diary-error">{{ error }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (report; as r) {
|
||||||
|
<div class="diary-summary">
|
||||||
|
<div class="diary-tile">
|
||||||
|
<span class="diary-tile-l">Total Investment</span
|
||||||
|
><span class="diary-tile-v">₹{{ r.total.totalAmount | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diary-tile">
|
||||||
|
<span class="diary-tile-l">Govt Securities (book value)</span
|
||||||
|
><span class="diary-tile-v">₹{{ r.total.govtBookValue | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
@if (hasBonds) {
|
||||||
|
<div class="diary-tile">
|
||||||
|
<span class="diary-tile-l">Bonds / Non-SLR (book value)</span
|
||||||
|
><span class="diary-tile-v">₹{{ r.total.bondBookValue | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="diary-tile">
|
||||||
|
<span class="diary-tile-l">FD with banks</span
|
||||||
|
><span class="diary-tile-v">₹{{ r.total.fdPrincipal | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">As on {{ r.asOnDate }} · by time left to maturity</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bucket</th>
|
||||||
|
<th class="num">Govt Sec</th>
|
||||||
|
<th class="num">Govt Sec Face Value</th>
|
||||||
|
<th class="num">Govt Sec Book Value</th>
|
||||||
|
@if (hasBonds) {
|
||||||
|
<th class="num">Bonds</th>
|
||||||
|
<th class="num">Bonds Book Value</th>
|
||||||
|
}
|
||||||
|
<th class="num">FD</th>
|
||||||
|
<th class="num">FD Principal</th>
|
||||||
|
<th class="num">Total Amount</th>
|
||||||
|
<th class="num">Share</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (b of r.buckets; track b.code) {
|
||||||
|
<tr class="report-pick" [class.report-pick--on]="bucket === b.code" (click)="pickBucket(b)">
|
||||||
|
<td>
|
||||||
|
<strong>{{ b.label }}</strong>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ b.govtCount }}</td>
|
||||||
|
<td class="num">₹{{ b.govtFaceValue | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ b.govtBookValue | number: '1.2-2' }}</td>
|
||||||
|
@if (hasBonds) {
|
||||||
|
<td class="num">{{ b.bondCount }}</td>
|
||||||
|
<td class="num">₹{{ b.bondBookValue | number: '1.2-2' }}</td>
|
||||||
|
}
|
||||||
|
<td class="num">{{ b.fdCount }}</td>
|
||||||
|
<td class="num">₹{{ b.fdPrincipal | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ b.totalAmount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ share(b) | number: '1.2-2' }}%</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="report-total">
|
||||||
|
<td>Total</td>
|
||||||
|
<td class="num">{{ r.total.govtCount }}</td>
|
||||||
|
<td class="num">₹{{ r.total.govtFaceValue | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ r.total.govtBookValue | number: '1.2-2' }}</td>
|
||||||
|
@if (hasBonds) {
|
||||||
|
<td class="num">{{ r.total.bondCount }}</td>
|
||||||
|
<td class="num">₹{{ r.total.bondBookValue | number: '1.2-2' }}</td>
|
||||||
|
}
|
||||||
|
<td class="num">{{ r.total.fdCount }}</td>
|
||||||
|
<td class="num">₹{{ r.total.fdPrincipal | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ r.total.totalAmount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ r.total.totalAmount > 0 ? '100.00%' : '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="inv-form-grid">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Bucket</span>
|
||||||
|
<select [(ngModel)]="bucket">
|
||||||
|
<option value="">All buckets</option>
|
||||||
|
@for (b of r.buckets; track b.code) {
|
||||||
|
<option [value]="b.code">{{ b.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Investment</span>
|
||||||
|
<select [(ngModel)]="instrument">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="GOVT_SECURITY">Govt Security</option>
|
||||||
|
@if (hasBonds) {
|
||||||
|
<option value="BOND">Bond / Non-SLR</option>
|
||||||
|
}
|
||||||
|
<option value="FD">FD with banks</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bucket</th>
|
||||||
|
<th>Investment</th>
|
||||||
|
<th>Deal No</th>
|
||||||
|
<th>Particulars</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th class="num">Rate</th>
|
||||||
|
<th>Maturity</th>
|
||||||
|
<th>Time Left</th>
|
||||||
|
<th class="num">Face Value / Principal</th>
|
||||||
|
<th class="num">Book Value / Principal</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (x of rows; track x.instrument + x.refId) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ bucketLabel(x.bucket) }}</td>
|
||||||
|
<td>{{ instrumentLabel(x.instrument) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ x.dealNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ x.particulars }}</td>
|
||||||
|
<td>{{ x.category || '—' }}</td>
|
||||||
|
<td class="num">{{ x.rate !== null && x.rate !== undefined ? x.rate + '%' : '—' }}</td>
|
||||||
|
<td>{{ x.maturityDate }}</td>
|
||||||
|
<td>
|
||||||
|
@if (x.daysToMaturity < 0) {
|
||||||
|
<span class="inv-badge diary-badge--overdue">{{ timeLeft(x) }}</span>
|
||||||
|
} @else {
|
||||||
|
{{ timeLeft(x) }}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ x.faceValue | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ x.bookValue | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="pg-empty">No investment held in this selection.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
@if (rows.length) {
|
||||||
|
<tfoot>
|
||||||
|
<tr class="report-total">
|
||||||
|
<td colspan="8">Total ({{ rows.length }})</td>
|
||||||
|
<td class="num">₹{{ rowsTotal.faceValue | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ rowsTotal.bookValue | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||||
|
import { todayDDMMYYYY } from '../investment-data';
|
||||||
|
import { exportWorkbook } from '../investment-export';
|
||||||
|
import {
|
||||||
|
InvestmentBucketDto,
|
||||||
|
InvestmentBucketReportDto,
|
||||||
|
InvestmentBucketRowDto,
|
||||||
|
InvestmentService,
|
||||||
|
apiErrorMessage
|
||||||
|
} from '../investment.service';
|
||||||
|
|
||||||
|
type InstrumentFilter = '' | InvestmentBucketRowDto['instrument'];
|
||||||
|
|
||||||
|
const INSTRUMENT_LABEL: Record<InvestmentBucketRowDto['instrument'], string> = {
|
||||||
|
GOVT_SECURITY: 'Govt Security',
|
||||||
|
BOND: 'Bond / Non-SLR',
|
||||||
|
FD: 'FD'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bucket Wise Investment: Govt Securities, bonds and FDs held on a date, split by the time left to maturity — up to
|
||||||
|
* 1 year, over 1 to 3, over 3 to 5 and over 5 years. Clicking a bucket lists the holdings in it.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-investment-bucket-report',
|
||||||
|
templateUrl: './investment-bucket-report.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../investment-shared.scss',
|
||||||
|
'../investment-diary/investment-diary.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
DateDdmmyyyyPickerComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class InvestmentBucketReportComponent implements OnInit {
|
||||||
|
private readonly investmentService = inject(InvestmentService);
|
||||||
|
|
||||||
|
asOnDate = todayDDMMYYYY();
|
||||||
|
bucket = '';
|
||||||
|
instrument: InstrumentFilter = '';
|
||||||
|
report: InvestmentBucketReportDto | null = null;
|
||||||
|
loading = false;
|
||||||
|
generating = false;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
if (!this.asOnDate) {
|
||||||
|
this.error = 'Enter the As on date.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.investmentService.getBucketReport(this.asOnDate).subscribe({
|
||||||
|
next: (r: InvestmentBucketReportDto) => {
|
||||||
|
this.report = r;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (err: unknown) => {
|
||||||
|
this.report = null;
|
||||||
|
this.loading = false;
|
||||||
|
this.error = apiErrorMessage(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bonds / Non-SLR columns only when any are held. */
|
||||||
|
get hasBonds(): boolean {
|
||||||
|
return (this.report?.total.bondCount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rows(): InvestmentBucketRowDto[] {
|
||||||
|
const all: InvestmentBucketRowDto[] = this.report?.rows ?? [];
|
||||||
|
return all.filter(
|
||||||
|
(r: InvestmentBucketRowDto) =>
|
||||||
|
(!this.bucket || r.bucket === this.bucket) && (!this.instrument || r.instrument === this.instrument)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get rowsTotal(): { faceValue: number; bookValue: number } {
|
||||||
|
return this.rows.reduce(
|
||||||
|
(t: { faceValue: number; bookValue: number }, r: InvestmentBucketRowDto) => ({
|
||||||
|
faceValue: t.faceValue + r.faceValue,
|
||||||
|
bookValue: t.bookValue + r.bookValue
|
||||||
|
}),
|
||||||
|
{ faceValue: 0, bookValue: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pickBucket(b: InvestmentBucketDto): void {
|
||||||
|
this.bucket = this.bucket === b.code ? '' : b.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketLabel(code: string): string {
|
||||||
|
return this.report?.buckets.find((b: InvestmentBucketDto) => b.code === code)?.label ?? code;
|
||||||
|
}
|
||||||
|
|
||||||
|
instrumentLabel(i: InvestmentBucketRowDto['instrument']): string {
|
||||||
|
return INSTRUMENT_LABEL[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share of the bucket in the total amount, in %. */
|
||||||
|
share(b: InvestmentBucketDto): number {
|
||||||
|
const total = this.report?.total.totalAmount ?? 0;
|
||||||
|
return total > 0 ? (b.totalAmount / total) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time left to maturity as years and months, or how long it is overdue. */
|
||||||
|
timeLeft(r: InvestmentBucketRowDto): string {
|
||||||
|
if (r.daysToMaturity < 0) {
|
||||||
|
return `Overdue ${-r.daysToMaturity} days`;
|
||||||
|
}
|
||||||
|
const months = Math.floor(r.daysToMaturity / 30.4375);
|
||||||
|
const years = Math.floor(months / 12);
|
||||||
|
if (years === 0 && months === 0) {
|
||||||
|
return `${r.daysToMaturity} days`;
|
||||||
|
}
|
||||||
|
return years > 0 ? `${years}y ${months % 12}m` : `${months}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
const report = this.report;
|
||||||
|
if (!report) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
const bucketRow = (b: InvestmentBucketDto): (string | number | null)[] => [
|
||||||
|
b.label,
|
||||||
|
b.govtCount,
|
||||||
|
b.govtFaceValue,
|
||||||
|
b.govtBookValue,
|
||||||
|
...(this.hasBonds ? [
|
||||||
|
b.bondCount,
|
||||||
|
b.bondFaceValue,
|
||||||
|
b.bondBookValue
|
||||||
|
] : []),
|
||||||
|
b.fdCount,
|
||||||
|
b.fdPrincipal,
|
||||||
|
b.totalAmount,
|
||||||
|
Number(this.share(b).toFixed(2))
|
||||||
|
];
|
||||||
|
await exportWorkbook(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sheetName: 'Bucket Summary',
|
||||||
|
title: 'Bucket Wise Investment',
|
||||||
|
subtitle: `As on ${report.asOnDate} · by time left to maturity`,
|
||||||
|
headers: [
|
||||||
|
'Bucket',
|
||||||
|
'Govt Sec (No.)',
|
||||||
|
'Govt Sec Face Value',
|
||||||
|
'Govt Sec Book Value',
|
||||||
|
...(this.hasBonds ? [
|
||||||
|
'Bonds (No.)',
|
||||||
|
'Bonds Face Value',
|
||||||
|
'Bonds Book Value'
|
||||||
|
] : []),
|
||||||
|
'FD (No.)',
|
||||||
|
'FD Principal',
|
||||||
|
'Total Amount',
|
||||||
|
'Share %'
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...report.buckets.map(bucketRow),
|
||||||
|
bucketRow(report.total)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheetName: 'Holdings',
|
||||||
|
title: 'Bucket Wise Investment — Holdings',
|
||||||
|
subtitle: `As on ${report.asOnDate}`,
|
||||||
|
headers: [
|
||||||
|
'Bucket',
|
||||||
|
'Investment',
|
||||||
|
'Deal No',
|
||||||
|
'Particulars',
|
||||||
|
'Category',
|
||||||
|
'Rate %',
|
||||||
|
'Maturity',
|
||||||
|
'Time Left',
|
||||||
|
'Face Value / Principal',
|
||||||
|
'Book Value / Principal'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
22,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
40,
|
||||||
|
10,
|
||||||
|
8,
|
||||||
|
12,
|
||||||
|
16,
|
||||||
|
20,
|
||||||
|
20
|
||||||
|
],
|
||||||
|
rows: report.rows.map((r: InvestmentBucketRowDto) => [
|
||||||
|
this.bucketLabel(r.bucket),
|
||||||
|
this.instrumentLabel(r.instrument),
|
||||||
|
r.dealNo,
|
||||||
|
r.particulars,
|
||||||
|
r.category ?? '',
|
||||||
|
r.rate ?? null,
|
||||||
|
r.maturityDate,
|
||||||
|
this.timeLeft(r),
|
||||||
|
r.faceValue,
|
||||||
|
r.bookValue
|
||||||
|
])
|
||||||
|
}
|
||||||
|
],
|
||||||
|
`Bucket-Wise-Investment-${report.asOnDate}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,34 +12,50 @@
|
|||||||
<div class="pg-eyebrow">TREASURY · INVESTMENT</div>
|
<div class="pg-eyebrow">TREASURY · INVESTMENT</div>
|
||||||
<h1 class="pg-title">Investment Portfolio Dashboard</h1>
|
<h1 class="pg-title">Investment Portfolio Dashboard</h1>
|
||||||
<p class="pg-sub">
|
<p class="pg-sub">
|
||||||
Consolidated position across FD, Govt Security, Non-SLR, Bonds, Mutual Fund and Share holdings as on
|
What the bank holds today across Govt Securities, FDs with banks, Non-SLR, Bonds, Mutual Funds and Shares, as on
|
||||||
{{ asOnDate }}.
|
{{ asOnDate }}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="pg-toolbar">
|
<div class="pg-toolbar">
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="loading" (click)="load()">
|
||||||
|
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating || loading" (click)="exportExcel()">
|
||||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<p class="inv-hint inv-hint--bad">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="inv-kpis">
|
<div class="inv-kpis">
|
||||||
<div class="inv-kpi">
|
<div class="inv-kpi">
|
||||||
<span class="inv-kpi__label">Total Holdings</span>
|
<span class="inv-kpi__label">Holdings</span>
|
||||||
<span class="inv-kpi__value">{{ totalHoldings }}</span>
|
<span class="inv-kpi__value">{{ totalHoldings }}</span>
|
||||||
<span class="inv-kpi__sub">{{ activeCount }} active · {{ maturedCount }} matured</span>
|
<span class="inv-kpi__sub"
|
||||||
|
>{{ maturingSoon.length }} due within 90 days{{ overdueCount ? ' (' + overdueCount + ' overdue)' : '' }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="inv-kpi">
|
<div class="inv-kpi">
|
||||||
<span class="inv-kpi__label">Total Invested (Face Value)</span>
|
<span class="inv-kpi__label">Total Invested (Book Value)</span>
|
||||||
<span class="inv-kpi__value">₹{{ totalFaceValue | number: '1.2-2' }}</span>
|
<span class="inv-kpi__value">₹{{ totalBookValue | number: '1.2-2' }}</span>
|
||||||
|
<span class="inv-kpi__sub">Face value ₹{{ totalFaceValue | number: '1.2-2' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="inv-kpi">
|
<div class="inv-kpi">
|
||||||
<span class="inv-kpi__label">Accrued Interest / Income</span>
|
<span class="inv-kpi__label">Interest Receivable</span>
|
||||||
<span class="inv-kpi__value col-in">₹{{ totalAccruedInterest | number: '1.2-2' }}</span>
|
<span class="inv-kpi__value col-in">₹{{ totalAccruedInterest | number: '1.2-2' }}</span>
|
||||||
|
<span class="inv-kpi__sub"
|
||||||
|
>Earned till today, not received · ₹{{ totalProvisioned | number: '1.2-2' }} provisioned</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="inv-kpi">
|
<div class="inv-kpi">
|
||||||
<span class="inv-kpi__label">Projected Value at Maturity</span>
|
<span class="inv-kpi__label">Value at Maturity</span>
|
||||||
<span class="inv-kpi__value">₹{{ totalMaturityValue | number: '1.2-2' }}</span>
|
<span class="inv-kpi__value">₹{{ totalMaturityValue | number: '1.2-2' }}</span>
|
||||||
|
<span class="inv-kpi__sub">Securities at face value, FDs with interest</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -51,7 +67,9 @@
|
|||||||
<th>Instrument Type</th>
|
<th>Instrument Type</th>
|
||||||
<th class="num">Holdings</th>
|
<th class="num">Holdings</th>
|
||||||
<th class="num">Face Value</th>
|
<th class="num">Face Value</th>
|
||||||
<th class="num col-in">Accrued Interest</th>
|
<th class="num">Book Value</th>
|
||||||
|
<th class="num">Share</th>
|
||||||
|
<th class="num col-in">Interest Receivable</th>
|
||||||
<th class="num col-net">Maturity Value</th>
|
<th class="num col-net">Maturity Value</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -62,6 +80,8 @@
|
|||||||
<td>{{ s.label }}</td>
|
<td>{{ s.label }}</td>
|
||||||
<td class="num">{{ s.count }}</td>
|
<td class="num">{{ s.count }}</td>
|
||||||
<td class="num">₹{{ s.faceValue | number: '1.2-2' }}</td>
|
<td class="num">₹{{ s.faceValue | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ s.bookValue | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ s.sharePct | number: '1.1-1' }}%</td>
|
||||||
<td class="num col-in">₹{{ s.accruedInterest | number: '1.2-2' }}</td>
|
<td class="num col-in">₹{{ s.accruedInterest | number: '1.2-2' }}</td>
|
||||||
<td class="num col-net">₹{{ s.maturityValue | number: '1.2-2' }}</td>
|
<td class="num col-net">₹{{ s.maturityValue | number: '1.2-2' }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -75,6 +95,8 @@
|
|||||||
<td>Total</td>
|
<td>Total</td>
|
||||||
<td class="num">{{ totalHoldings }}</td>
|
<td class="num">{{ totalHoldings }}</td>
|
||||||
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
|
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ totalBookValue | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ totalBookValue > 0 ? '100%' : '—' }}</td>
|
||||||
<td class="num">₹{{ totalAccruedInterest | number: '1.2-2' }}</td>
|
<td class="num">₹{{ totalAccruedInterest | number: '1.2-2' }}</td>
|
||||||
<td class="num">₹{{ totalMaturityValue | number: '1.2-2' }}</td>
|
<td class="num">₹{{ totalMaturityValue | number: '1.2-2' }}</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
@@ -84,93 +106,70 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">Portfolio Allocation — Target vs Actual</div>
|
<div class="pos-section-label">RBI Limits</div>
|
||||||
<p class="pg-sub">
|
@if (limitNote) {
|
||||||
Govt Security, Non-SLR, Mutual Fund and Share targets trace to RBI's SLR floor (18% of NDTL) and Non-SLR ceiling
|
<p class="inv-hint inv-hint--bad">{{ limitNote }}</p>
|
||||||
(≤10% of deposits) for Urban Co-operative Banks; Fixed Deposit and Bond are board-approved policy choices within
|
}
|
||||||
the room that framework leaves.
|
@if (limits.length) {
|
||||||
</p>
|
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Instrument Type</th>
|
<th>Limit</th>
|
||||||
<th>Target Basis</th>
|
<th>Basis</th>
|
||||||
<th class="num">Target %</th>
|
<th class="num">Allowed</th>
|
||||||
<th class="num">Actual %</th>
|
<th class="num">Used</th>
|
||||||
<th class="num">Actual Value</th>
|
<th class="num">Used %</th>
|
||||||
<th class="num">Variance</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (a of allocationVsTarget; track a.type) {
|
@for (l of limits; track l.label) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ a.label }}</td>
|
<td>{{ l.label }}</td>
|
||||||
|
<td>{{ l.basis }}</td>
|
||||||
|
<td class="num">₹{{ l.limit | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ l.used | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ l.usedPct === null ? '—' : (l.usedPct | number: '1.1-1') + '%' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="inv-badge" [class.inv-badge--matured]="a.isRbiLinked">
|
<span class="inv-badge" [class.inv-badge--active]="l.within" [class.inv-badge--written_off]="!l.within">
|
||||||
{{ a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy' }}
|
{{ l.within ? 'Within' : 'Above limit' }}
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="num">{{ a.targetPct }}%</td>
|
|
||||||
<td class="num">{{ a.actualPct }}%</td>
|
|
||||||
<td class="num">₹{{ a.actualValue | number: '1.2-2' }}</td>
|
|
||||||
<td
|
|
||||||
class="num"
|
|
||||||
[class.col-out]="a.statusLabel !== 'Within Target'"
|
|
||||||
[class.col-in]="a.statusLabel === 'Within Target'"
|
|
||||||
>
|
|
||||||
{{ a.variancePct > 0 ? '+' : '' }}{{ a.variancePct }}%
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="inv-badge"
|
|
||||||
[class.inv-badge--active]="a.statusLabel === 'Within Target'"
|
|
||||||
[class.inv-badge--sold]="a.statusLabel === 'Over-Allocated'"
|
|
||||||
[class.inv-badge--written_off]="a.statusLabel === 'Under-Allocated'"
|
|
||||||
>
|
|
||||||
{{ a.statusLabel }}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
|
||||||
<tr class="cctr-total-row">
|
|
||||||
<td>Total</td>
|
|
||||||
<td class="num">100%</td>
|
|
||||||
<td class="num">100%</td>
|
|
||||||
<td class="num">₹{{ totalFaceValue | number: '1.2-2' }}</td>
|
|
||||||
<td colspan="2"></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
</table>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">Due-Date Diary — Maturing Within 90 Days</div>
|
<div class="pos-section-label">Maturing Within 90 Days</div>
|
||||||
@if (maturingSoon.length === 0) {
|
@if (maturingSoon.length === 0) {
|
||||||
<div class="pg-empty">No holdings maturing in the next 90 days.</div>
|
<div class="pg-empty">No holdings maturing in the next 90 days.</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div>
|
<div>
|
||||||
@for (h of maturingSoon; track h.certNo) {
|
@for (m of maturingSoon; track m.dealNo) {
|
||||||
<div class="inv-maturity-row">
|
<div class="inv-maturity-row">
|
||||||
<span>
|
<span>
|
||||||
<span class="pg-chip">{{ h.certNo }}</span>
|
<span class="pg-chip">{{ m.dealNo }}</span>
|
||||||
{{ h.scriptName }} — {{ h.party }} ({{ instrumentLabel(h.instrumentType) }})
|
{{ m.particulars }} ({{ m.instrument }})
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Matures {{ h.maturityDate }} ·
|
Matures {{ m.maturityDate }} · ₹{{ m.amount | number: '1.2-2' }} ·
|
||||||
<span
|
<span
|
||||||
class="inv-maturity-days"
|
class="inv-maturity-days"
|
||||||
[class.inv-maturity-days--soon]="daysLeft(h) <= 30"
|
[class.inv-maturity-days--soon]="m.days <= 30"
|
||||||
[class.inv-maturity-days--ok]="daysLeft(h) > 30"
|
[class.inv-maturity-days--ok]="m.days > 30"
|
||||||
>
|
>
|
||||||
{{ daysLeft(h) }} days
|
{{ m.days < 0 ? 'Overdue ' + -m.days + ' days' : m.days + ' days' }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="inv-form-actions" style="padding: 8px 16px">
|
||||||
|
<a class="pg-btn pg-btn--ghost" routerLink="/investment/diary">Open Due Date Diary</a>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,19 +178,17 @@
|
|||||||
<div class="inv-nav-grid" style="padding: 16px">
|
<div class="inv-nav-grid" style="padding: 16px">
|
||||||
<a class="inv-nav-card" routerLink="/investment/fd">
|
<a class="inv-nav-card" routerLink="/investment/fd">
|
||||||
<span class="inv-nav-card__label">Fixed Deposit</span>
|
<span class="inv-nav-card__label">Fixed Deposit</span>
|
||||||
<span class="inv-nav-card__count">{{ summaries[0].count }}</span>
|
<span class="inv-nav-card__count">{{ countOf('FD') }}</span>
|
||||||
<span class="inv-nav-card__value">Purchase / Interest / Sell / Write-off</span>
|
<span class="inv-nav-card__value">Purchase / Interest / Maturity / Renewal</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="inv-nav-card" routerLink="/investment/bonds">
|
<a class="inv-nav-card" routerLink="/investment/bonds">
|
||||||
<span class="inv-nav-card__label">Govt Security / Non-SLR / Bonds / MF</span>
|
<span class="inv-nav-card__label">Govt Security / Non-SLR / Bonds / MF</span>
|
||||||
<span class="inv-nav-card__count">{{
|
<span class="inv-nav-card__count">{{ countOf('GOVT_SECURITY', 'NON_SLR', 'BOND', 'MUTUAL_FUND') }}</span>
|
||||||
summaries[1].count + summaries[2].count + summaries[3].count + summaries[4].count
|
|
||||||
}}</span>
|
|
||||||
<span class="inv-nav-card__value">Purchase / Interest / Sell / Amortize</span>
|
<span class="inv-nav-card__value">Purchase / Interest / Sell / Amortize</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="inv-nav-card" routerLink="/investment/share">
|
<a class="inv-nav-card" routerLink="/investment/share">
|
||||||
<span class="inv-nav-card__label">Share Entry</span>
|
<span class="inv-nav-card__label">Share Entry</span>
|
||||||
<span class="inv-nav-card__count">{{ summaries[5].count }}</span>
|
<span class="inv-nav-card__count">{{ countOf('SHARE') }}</span>
|
||||||
<span class="inv-nav-card__value">Purchase / Dividend / Sell / Write-off</span>
|
<span class="inv-nav-card__value">Purchase / Dividend / Sell / Write-off</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="inv-nav-card" routerLink="/investment/interest">
|
<a class="inv-nav-card" routerLink="/investment/interest">
|
||||||
|
|||||||
@@ -6,21 +6,23 @@
|
|||||||
* 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 } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import {
|
import { forkJoin } from 'rxjs';
|
||||||
ALL_HOLDINGS,
|
import { INSTRUMENT_LABELS, InstrumentType, parseDDMMYYYY, todayDDMMYYYY } from '../investment-data';
|
||||||
ALLOCATION_TARGET_IS_RBI_LINKED,
|
|
||||||
INSTRUMENT_LABELS,
|
|
||||||
InstrumentType,
|
|
||||||
InvestmentHolding,
|
|
||||||
TARGET_ALLOCATION_PCT,
|
|
||||||
accruedInterest,
|
|
||||||
daysToMaturity,
|
|
||||||
maturityAmount
|
|
||||||
} from '../investment-data';
|
|
||||||
import { exportWorkbook } from '../investment-export';
|
import { exportWorkbook } from '../investment-export';
|
||||||
|
import {
|
||||||
|
InvestmentBucketReportDto,
|
||||||
|
InvestmentBucketRowDto,
|
||||||
|
InvestmentFdDto,
|
||||||
|
InvestmentFdLimitDto,
|
||||||
|
InvestmentInterestReportDto,
|
||||||
|
InvestmentNonSlrLimitDto,
|
||||||
|
InvestmentPurchaseDto,
|
||||||
|
InvestmentService,
|
||||||
|
apiErrorMessage
|
||||||
|
} from '../investment.service';
|
||||||
|
|
||||||
interface InstrumentSummary {
|
interface InstrumentSummary {
|
||||||
type: InstrumentType;
|
type: InstrumentType;
|
||||||
@@ -28,25 +30,45 @@ interface InstrumentSummary {
|
|||||||
route: string;
|
route: string;
|
||||||
count: number;
|
count: number;
|
||||||
faceValue: number;
|
faceValue: number;
|
||||||
|
bookValue: number;
|
||||||
accruedInterest: number;
|
accruedInterest: number;
|
||||||
maturityValue: number;
|
maturityValue: number;
|
||||||
|
sharePct: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AllocationRow {
|
interface LimitRow {
|
||||||
type: InstrumentType;
|
|
||||||
label: string;
|
label: string;
|
||||||
targetPct: number;
|
basis: string;
|
||||||
actualPct: number;
|
limit: number | null;
|
||||||
actualValue: number;
|
used: number;
|
||||||
variancePct: number;
|
usedPct: number | null;
|
||||||
statusLabel: 'Within Target' | 'Over-Allocated' | 'Under-Allocated';
|
within: boolean;
|
||||||
/** True when the target itself traces to an RBI SLR/Non-SLR rule rather than being a
|
|
||||||
* board-only policy choice — see TARGET_ALLOCATION_PCT's doc comment in investment-data.ts. */
|
|
||||||
isRbiLinked: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cross-instrument portfolio dashboard — every figure is derived from ALL_HOLDINGS, never
|
/** A holding maturing soon, or past maturity and not yet redeemed / closed. */
|
||||||
* hand-duplicated, so it can never drift from the per-instrument screens or Reports. */
|
interface MaturingRow {
|
||||||
|
dealNo: string;
|
||||||
|
particulars: string;
|
||||||
|
instrument: string;
|
||||||
|
maturityDate: string;
|
||||||
|
days: number;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTE: Record<InstrumentType, string> = {
|
||||||
|
FD: '/investment/fd',
|
||||||
|
GOVT_SECURITY: '/investment/bonds',
|
||||||
|
NON_SLR: '/investment/bonds',
|
||||||
|
BOND: '/investment/bonds',
|
||||||
|
MUTUAL_FUND: '/investment/bonds',
|
||||||
|
SHARE: '/investment/share'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-instrument portfolio dashboard on live data: what is held today by instrument (face value, book value,
|
||||||
|
* interest earned and not yet received, value at maturity), where the portfolio stands against the RBI limits, and
|
||||||
|
* what matures in the next 90 days.
|
||||||
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-investment-dashboard',
|
selector: 'mifosx-investment-dashboard',
|
||||||
templateUrl: './investment-dashboard.component.html',
|
templateUrl: './investment-dashboard.component.html',
|
||||||
@@ -60,107 +82,216 @@ interface AllocationRow {
|
|||||||
RouterLink
|
RouterLink
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class InvestmentDashboardComponent {
|
export class InvestmentDashboardComponent implements OnInit {
|
||||||
asOnDate = '';
|
private readonly investmentService = inject(InvestmentService);
|
||||||
|
|
||||||
|
asOnDate = todayDDMMYYYY();
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
generating = false;
|
generating = false;
|
||||||
toastMsg = '';
|
toastMsg = '';
|
||||||
toastVisible = false;
|
toastVisible = false;
|
||||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
private readonly types: Array<{ type: InstrumentType; route: string }> = [
|
|
||||||
{ type: 'FD', route: '/investment/fd' },
|
summaries: InstrumentSummary[] = [];
|
||||||
{ type: 'GOVT_SECURITY', route: '/investment/bonds' },
|
limits: LimitRow[] = [];
|
||||||
{ type: 'NON_SLR', route: '/investment/bonds' },
|
limitNote = '';
|
||||||
{ type: 'BOND', route: '/investment/bonds' },
|
maturingSoon: MaturingRow[] = [];
|
||||||
{ type: 'MUTUAL_FUND', route: '/investment/bonds' },
|
totalReceivable = 0;
|
||||||
{ type: 'SHARE', route: '/investment/share' }
|
totalProvisioned = 0;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
const today = this.asOnDate;
|
||||||
|
const d = parseDDMMYYYY(today);
|
||||||
|
const fyStart = `01-04-${d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1}`;
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
forkJoin({
|
||||||
|
bucket: this.investmentService.getBucketReport(today),
|
||||||
|
interest: this.investmentService.getInterestReport(fyStart, today),
|
||||||
|
purchases: this.investmentService.getPurchases(),
|
||||||
|
fds: this.investmentService.getFds(),
|
||||||
|
fdQuote: this.investmentService.quoteFd({ amount: 0, intRate: 0, dealDate: today }),
|
||||||
|
nonSlr: this.investmentService.getNonSlrLimit(null, today, 0)
|
||||||
|
}).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.build(r.bucket, r.interest, r.purchases, r.fds, r.fdQuote.limit ?? null, r.nonSlr);
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (err: unknown) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.error = apiErrorMessage(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private build(
|
||||||
|
bucket: InvestmentBucketReportDto,
|
||||||
|
interest: InvestmentInterestReportDto,
|
||||||
|
purchases: InvestmentPurchaseDto[],
|
||||||
|
fds: InvestmentFdDto[],
|
||||||
|
fdLimit: InvestmentFdLimitDto | null,
|
||||||
|
nonSlr: InvestmentNonSlrLimitDto
|
||||||
|
): void {
|
||||||
|
const purchaseById = new Map<number, InvestmentPurchaseDto>(
|
||||||
|
purchases.map((p) => [
|
||||||
|
p.id,
|
||||||
|
p
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const fdById = new Map<number, InvestmentFdDto>(
|
||||||
|
fds
|
||||||
|
.filter((f) => f.id != null)
|
||||||
|
.map((f) => [
|
||||||
|
f.id as number,
|
||||||
|
f
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const receivable = new Map<string, number>(
|
||||||
|
interest.rows.map((x) => [
|
||||||
|
x.instrument + ':' + x.refId,
|
||||||
|
x.receivable
|
||||||
|
])
|
||||||
|
);
|
||||||
|
this.totalReceivable = interest.totalReceivable;
|
||||||
|
this.totalProvisioned = interest.totalProvisioned;
|
||||||
|
|
||||||
|
// Bucket rows are the Govt Securities, bonds and FDs held today; the purchase tells a Non-SLR from a Bond.
|
||||||
|
const typeOf = (r: InvestmentBucketRowDto): InstrumentType =>
|
||||||
|
r.instrument === 'FD' ? 'FD' : (purchaseById.get(r.refId)?.bondType ?? 'BOND');
|
||||||
|
const acc = new Map<InstrumentType, InstrumentSummary>();
|
||||||
|
const sum = (type: InstrumentType): InstrumentSummary => {
|
||||||
|
let s = acc.get(type);
|
||||||
|
if (!s) {
|
||||||
|
s = {
|
||||||
|
type,
|
||||||
|
label: INSTRUMENT_LABELS[type],
|
||||||
|
route: ROUTE[type],
|
||||||
|
count: 0,
|
||||||
|
faceValue: 0,
|
||||||
|
bookValue: 0,
|
||||||
|
accruedInterest: 0,
|
||||||
|
maturityValue: 0,
|
||||||
|
sharePct: 0
|
||||||
|
};
|
||||||
|
acc.set(type, s);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
for (const r of bucket.rows) {
|
||||||
|
const s = sum(typeOf(r));
|
||||||
|
s.count += 1;
|
||||||
|
s.faceValue += r.faceValue;
|
||||||
|
s.bookValue += r.bookValue;
|
||||||
|
s.accruedInterest += receivable.get((r.instrument === 'FD' ? 'FD' : 'SECURITY') + ':' + r.refId) ?? 0;
|
||||||
|
// A security is redeemed at face value; an FD pays its maturity amount (just the principal on a payout FD).
|
||||||
|
s.maturityValue +=
|
||||||
|
r.instrument === 'FD' ? (fdById.get(r.refId)?.expectedMaturityAmount ?? r.faceValue) : r.faceValue;
|
||||||
|
}
|
||||||
|
// Mutual funds and shares have no maturity, so they are not in the buckets; their units are held at cost.
|
||||||
|
for (const p of purchases) {
|
||||||
|
if ((p.bondType === 'MUTUAL_FUND' || p.bondType === 'SHARE') && p.status === 'ACTIVE') {
|
||||||
|
const s = sum(p.bondType);
|
||||||
|
s.count += 1;
|
||||||
|
s.faceValue += p.faceValue;
|
||||||
|
s.bookValue += p.bookValue;
|
||||||
|
s.maturityValue += p.bookValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const order: InstrumentType[] = [
|
||||||
|
'GOVT_SECURITY',
|
||||||
|
'FD',
|
||||||
|
'NON_SLR',
|
||||||
|
'BOND',
|
||||||
|
'MUTUAL_FUND',
|
||||||
|
'SHARE'
|
||||||
];
|
];
|
||||||
|
const total = [...acc.values()].reduce((t, s) => t + s.bookValue, 0);
|
||||||
|
this.summaries = order
|
||||||
|
.map((t) => sum(t))
|
||||||
|
.map((s) => ({ ...s, sharePct: total > 0 ? (s.bookValue / total) * 100 : 0 }));
|
||||||
|
|
||||||
constructor() {
|
this.limits = [];
|
||||||
const d = new Date();
|
this.limitNote = '';
|
||||||
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
if (fdLimit?.grossLimit != null) {
|
||||||
|
const used = fdLimit.fdOutstanding + fdLimit.bankBalances;
|
||||||
|
this.limits.push(
|
||||||
|
this.limitRow(
|
||||||
|
'Deposits with other banks',
|
||||||
|
'FDs + current accounts, ≤ 20% of deposits',
|
||||||
|
fdLimit.grossLimit,
|
||||||
|
used
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (nonSlr.nonSlrLimit != null) {
|
||||||
|
this.limits.push(
|
||||||
|
this.limitRow(
|
||||||
|
'Non-SLR investments',
|
||||||
|
'Bonds, CP / CD, MF, ≤ 10% of deposits',
|
||||||
|
nonSlr.nonSlrLimit,
|
||||||
|
nonSlr.nonSlrOutstanding
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.limits.push(
|
||||||
|
this.limitRow(
|
||||||
|
'Unlisted non-SLR',
|
||||||
|
`≤ 10% of non-SLR held on ${nonSlr.asOnDate}`,
|
||||||
|
nonSlr.unlistedLimit,
|
||||||
|
nonSlr.unlistedOutstanding
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.limitNote = `Total deposits as on ${nonSlr.asOnDate} are not entered yet; enter them on the FD screen to see the RBI limits.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private holdingsOf(type: InstrumentType): InvestmentHolding[] {
|
this.maturingSoon = bucket.rows
|
||||||
return ALL_HOLDINGS.filter((h) => h.instrumentType === type);
|
.filter((r) => r.daysToMaturity <= 90)
|
||||||
|
.map((r) => ({
|
||||||
|
dealNo: r.dealNo,
|
||||||
|
particulars: r.particulars,
|
||||||
|
instrument: INSTRUMENT_LABELS[typeOf(r)],
|
||||||
|
maturityDate: r.maturityDate,
|
||||||
|
days: r.daysToMaturity,
|
||||||
|
amount: r.instrument === 'FD' ? (fdById.get(r.refId)?.expectedMaturityAmount ?? r.faceValue) : r.faceValue
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
get summaries(): InstrumentSummary[] {
|
private limitRow(label: string, basis: string, limit: number, used: number): LimitRow {
|
||||||
return this.types.map(({ type, route }) => {
|
return { label, basis, limit, used, usedPct: limit > 0 ? (used / limit) * 100 : null, within: used <= limit };
|
||||||
const holdings = this.holdingsOf(type);
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
label: INSTRUMENT_LABELS[type],
|
|
||||||
route,
|
|
||||||
count: holdings.length,
|
|
||||||
faceValue: holdings.reduce((s, h) => s + h.faceValue, 0),
|
|
||||||
accruedInterest: holdings.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0),
|
|
||||||
maturityValue: holdings.reduce((s, h) => s + maturityAmount(h), 0)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Board-approved policy target % per instrument type vs the portfolio's actual allocation
|
|
||||||
* (by invested/face value) — a ±2 percentage-point band is treated as "Within Target" since
|
|
||||||
* a treasury desk can't rebalance to the exact decimal every day. */
|
|
||||||
get allocationVsTarget(): AllocationRow[] {
|
|
||||||
const total = this.totalFaceValue;
|
|
||||||
return this.types.map(({ type }) => {
|
|
||||||
const value = this.holdingsOf(type).reduce((s, h) => s + h.faceValue, 0);
|
|
||||||
const actualPct = total ? (value / total) * 100 : 0;
|
|
||||||
const targetPct = TARGET_ALLOCATION_PCT[type];
|
|
||||||
const variancePct = +(actualPct - targetPct).toFixed(1);
|
|
||||||
const statusLabel: AllocationRow['statusLabel'] =
|
|
||||||
Math.abs(variancePct) <= 2 ? 'Within Target' : variancePct > 0 ? 'Over-Allocated' : 'Under-Allocated';
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
label: INSTRUMENT_LABELS[type],
|
|
||||||
targetPct,
|
|
||||||
actualPct: +actualPct.toFixed(1),
|
|
||||||
actualValue: value,
|
|
||||||
variancePct,
|
|
||||||
statusLabel,
|
|
||||||
isRbiLinked: ALLOCATION_TARGET_IS_RBI_LINKED[type]
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get totalHoldings(): number {
|
get totalHoldings(): number {
|
||||||
return ALL_HOLDINGS.length;
|
return this.summaries.reduce((t, s) => t + s.count, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
get totalFaceValue(): number {
|
get totalFaceValue(): number {
|
||||||
return ALL_HOLDINGS.reduce((s, h) => s + h.faceValue, 0);
|
return this.summaries.reduce((t, s) => t + s.faceValue, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalBookValue(): number {
|
||||||
|
return this.summaries.reduce((t, s) => t + s.bookValue, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
get totalAccruedInterest(): number {
|
get totalAccruedInterest(): number {
|
||||||
return ALL_HOLDINGS.reduce((s, h) => s + accruedInterest(h, this.asOnDate), 0);
|
return this.summaries.reduce((t, s) => t + s.accruedInterest, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
get totalMaturityValue(): number {
|
get totalMaturityValue(): number {
|
||||||
return ALL_HOLDINGS.reduce((s, h) => s + maturityAmount(h), 0);
|
return this.summaries.reduce((t, s) => t + s.maturityValue, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
get activeCount(): number {
|
get overdueCount(): number {
|
||||||
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE').length;
|
return this.maturingSoon.filter((m) => m.days < 0).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
get maturedCount(): number {
|
/** Holdings of one or more instrument types, for the Quick Entry cards. */
|
||||||
return ALL_HOLDINGS.filter((h) => h.status === 'MATURED').length;
|
countOf(...types: InstrumentType[]): number {
|
||||||
}
|
return this.summaries.filter((s) => types.includes(s.type)).reduce((t, s) => t + s.count, 0);
|
||||||
|
|
||||||
/** Holdings maturing within the next 90 days — a "due-date diary" style widget. */
|
|
||||||
get maturingSoon(): InvestmentHolding[] {
|
|
||||||
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && daysToMaturity(h, this.asOnDate) <= 90).sort(
|
|
||||||
(a, b) => daysToMaturity(a, this.asOnDate) - daysToMaturity(b, this.asOnDate)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
daysLeft(h: InvestmentHolding): number {
|
|
||||||
return daysToMaturity(h, this.asOnDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
instrumentLabel(type: InstrumentType): string {
|
|
||||||
return INSTRUMENT_LABELS[type];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportExcel(): Promise<void> {
|
async exportExcel(): Promise<void> {
|
||||||
@@ -176,54 +307,97 @@ export class InvestmentDashboardComponent {
|
|||||||
'Instrument Type',
|
'Instrument Type',
|
||||||
'Holdings',
|
'Holdings',
|
||||||
'Face Value',
|
'Face Value',
|
||||||
'Accrued Interest',
|
'Book Value',
|
||||||
|
'Share %',
|
||||||
|
'Interest Receivable',
|
||||||
'Maturity Value'
|
'Maturity Value'
|
||||||
],
|
],
|
||||||
widths: [
|
widths: [
|
||||||
26,
|
22,
|
||||||
12,
|
10,
|
||||||
18,
|
18,
|
||||||
18,
|
18,
|
||||||
|
10,
|
||||||
|
18,
|
||||||
18
|
18
|
||||||
],
|
],
|
||||||
rows: this.summaries.map((s) => [
|
rows: [
|
||||||
|
...this.summaries.map((s) => [
|
||||||
s.label,
|
s.label,
|
||||||
s.count,
|
s.count,
|
||||||
s.faceValue,
|
s.faceValue,
|
||||||
|
s.bookValue,
|
||||||
|
Number(s.sharePct.toFixed(2)),
|
||||||
s.accruedInterest,
|
s.accruedInterest,
|
||||||
s.maturityValue
|
s.maturityValue
|
||||||
])
|
]),
|
||||||
|
[
|
||||||
|
'Total',
|
||||||
|
this.totalHoldings,
|
||||||
|
this.totalFaceValue,
|
||||||
|
this.totalBookValue,
|
||||||
|
100,
|
||||||
|
this.totalAccruedInterest,
|
||||||
|
this.totalMaturityValue
|
||||||
|
]
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sheetName: 'Portfolio Allocation',
|
sheetName: 'RBI Limits',
|
||||||
title: 'Portfolio Allocation — Target vs Actual',
|
title: 'RBI Limits',
|
||||||
subtitle: `As on ${this.asOnDate}`,
|
subtitle: `As on ${this.asOnDate}`,
|
||||||
headers: [
|
headers: [
|
||||||
'Instrument Type',
|
'Limit',
|
||||||
'Target Basis',
|
'Basis',
|
||||||
'Target %',
|
'Allowed',
|
||||||
'Actual %',
|
'Used',
|
||||||
'Actual Value',
|
'Used %',
|
||||||
'Variance %',
|
|
||||||
'Status'
|
'Status'
|
||||||
],
|
],
|
||||||
widths: [
|
widths: [
|
||||||
26,
|
26,
|
||||||
20,
|
40,
|
||||||
12,
|
|
||||||
12,
|
|
||||||
18,
|
18,
|
||||||
|
18,
|
||||||
|
10,
|
||||||
|
14
|
||||||
|
],
|
||||||
|
rows: this.limits.map((l) => [
|
||||||
|
l.label,
|
||||||
|
l.basis,
|
||||||
|
l.limit,
|
||||||
|
l.used,
|
||||||
|
l.usedPct === null ? null : Number(l.usedPct.toFixed(2)),
|
||||||
|
l.within ? 'Within' : 'Above limit'
|
||||||
|
])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sheetName: 'Maturing in 90 days',
|
||||||
|
title: 'Maturing Within 90 Days (and overdue)',
|
||||||
|
subtitle: `As on ${this.asOnDate}`,
|
||||||
|
headers: [
|
||||||
|
'Deal No',
|
||||||
|
'Investment',
|
||||||
|
'Particulars',
|
||||||
|
'Maturity',
|
||||||
|
'Days',
|
||||||
|
'Amount'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
40,
|
||||||
12,
|
12,
|
||||||
|
10,
|
||||||
18
|
18
|
||||||
],
|
],
|
||||||
rows: this.allocationVsTarget.map((a) => [
|
rows: this.maturingSoon.map((m) => [
|
||||||
a.label,
|
m.dealNo,
|
||||||
a.isRbiLinked ? 'RBI SLR / Non-SLR Norm' : 'Board Policy',
|
m.instrument,
|
||||||
a.targetPct,
|
m.particulars,
|
||||||
a.actualPct,
|
m.maturityDate,
|
||||||
a.actualValue,
|
m.days,
|
||||||
a.variancePct,
|
m.amount
|
||||||
a.statusLabel
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type InstrumentType = 'FD' | 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND' | 'SHARE';
|
export type InstrumentType = 'FD' | 'GOVT_SECURITY' | 'NON_SLR' | 'BOND' | 'MUTUAL_FUND' | 'SHARE';
|
||||||
export type HoldingStatus = 'ACTIVE' | 'MATURED' | 'SOLD' | 'WRITTEN_OFF';
|
export type HoldingStatus = 'PENDING_SETTLEMENT' | 'ACTIVE' | 'MATURED' | 'SOLD' | 'WRITTEN_OFF';
|
||||||
export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' | 'CUMULATIVE';
|
export type InterestFreq = 'MONTHLY' | 'QUARTERLY' | 'HALF_YEARLY' | 'YEARLY' | 'CUMULATIVE';
|
||||||
export type TxnType =
|
export type TxnType =
|
||||||
| 'PURCHASE'
|
| 'PURCHASE'
|
||||||
@@ -28,6 +28,7 @@ export type TxnType =
|
|||||||
| 'REDEEM'
|
| 'REDEEM'
|
||||||
| 'WRITE_OFF'
|
| 'WRITE_OFF'
|
||||||
| 'AMORTIZE'
|
| 'AMORTIZE'
|
||||||
|
| 'TRANSFER'
|
||||||
| 'PART_PAYMENT';
|
| 'PART_PAYMENT';
|
||||||
|
|
||||||
export const INSTRUMENT_LABELS: Record<InstrumentType, string> = {
|
export const INSTRUMENT_LABELS: Record<InstrumentType, string> = {
|
||||||
@@ -123,6 +124,22 @@ export interface InvestmentHolding {
|
|||||||
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping
|
* never re-counts days up to this date, so re-running a Calculation & Post for an overlapping
|
||||||
* or repeated period always yields 0 instead of double-posting the same interest. */
|
* or repeated period always yields 0 instead of double-posting the same interest. */
|
||||||
interestPostedTillDate?: string;
|
interestPostedTillDate?: string;
|
||||||
|
/** RBI investment category chosen at purchase — HTM / AFS / HFT. */
|
||||||
|
category?: string;
|
||||||
|
/** SGL / CSGL account the security is held in (custody, not an accounting GL). */
|
||||||
|
sglAccountNo?: string;
|
||||||
|
/** Backend id / deal number / party code once the purchase is saved (absent on sample rows). */
|
||||||
|
id?: number;
|
||||||
|
dealNo?: string;
|
||||||
|
partyCode?: string;
|
||||||
|
bookValue?: number;
|
||||||
|
settlementAmount?: number;
|
||||||
|
/** Settlement date of the deal (deal date + T+n) — it can be settled on or after this day. */
|
||||||
|
settlementDate?: string;
|
||||||
|
/** Last coupon date before the purchase; coupons fall every six months from it. */
|
||||||
|
lastIntDate?: string;
|
||||||
|
/** Journal entry transaction posted when the deal was settled. */
|
||||||
|
glTransactionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvestmentTxn {
|
export interface InvestmentTxn {
|
||||||
@@ -248,6 +265,80 @@ export function accruedInterestBetween(h: InvestmentHolding, fromStr: string, to
|
|||||||
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
|
return Math.round(h.faceValue * (h.interestRate / 100) * (days / 365.25));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Days between two DD-MM-YYYY dates on a 30/360 basis (every month 30 days), as the bank's existing
|
||||||
|
* CBS counts them — e.g. 10-08 → 30-09 = 50 days, 10-08 → 07-10 = 57 days. */
|
||||||
|
export function days360(fromStr: string, toStr: string): number {
|
||||||
|
const a = parseDDMMYYYY(fromStr);
|
||||||
|
const b = parseDDMMYYYY(toStr);
|
||||||
|
if (isNaN(a.getTime()) || isNaN(b.getTime()) || b <= a) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const d1 = Math.min(a.getDate(), 30);
|
||||||
|
const d2 = Math.min(b.getDate(), 30);
|
||||||
|
return (b.getFullYear() - a.getFullYear()) * 360 + (b.getMonth() - a.getMonth()) * 30 + (d2 - d1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most recent half-yearly coupon date on or before `onStr`, derived from the maturity date (coupons
|
||||||
|
* fall on the maturity day/month and six months either side of it). */
|
||||||
|
export function lastHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
|
||||||
|
const mat = parseDDMMYYYY(maturityStr);
|
||||||
|
const on = parseDDMMYYYY(onStr);
|
||||||
|
if (isNaN(mat.getTime()) || isNaN(on.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const day = mat.getDate();
|
||||||
|
const couponOn = (year: number, month: number): Date => {
|
||||||
|
const lastDay = new Date(year, month + 1, 0).getDate();
|
||||||
|
return new Date(year, month, Math.min(day, lastDay));
|
||||||
|
};
|
||||||
|
const months = [
|
||||||
|
mat.getMonth() % 6,
|
||||||
|
(mat.getMonth() % 6) + 6
|
||||||
|
];
|
||||||
|
let best: Date | undefined;
|
||||||
|
for (const year of [
|
||||||
|
on.getFullYear() - 1,
|
||||||
|
on.getFullYear()
|
||||||
|
]) {
|
||||||
|
for (const m of months) {
|
||||||
|
const c = couponOn(year, m);
|
||||||
|
if (c <= on && (!best || c > best)) {
|
||||||
|
best = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ? formatDDMMYYYY(best) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First half-yearly coupon date after `onStr` (coupons fall on the maturity day/month and six months
|
||||||
|
* either side), never later than maturity. */
|
||||||
|
export function nextHalfYearlyCouponDate(maturityStr: string, onStr: string): string {
|
||||||
|
const last = lastHalfYearlyCouponDate(maturityStr, onStr);
|
||||||
|
const mat = parseDDMMYYYY(maturityStr);
|
||||||
|
if (!last || isNaN(mat.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const l = parseDDMMYYYY(last);
|
||||||
|
const year = l.getFullYear() + (l.getMonth() >= 6 ? 1 : 0);
|
||||||
|
const month = (l.getMonth() + 6) % 12;
|
||||||
|
const next = new Date(year, month, Math.min(mat.getDate(), new Date(year, month + 1, 0).getDate()));
|
||||||
|
return formatDDMMYYYY(next > mat ? mat : next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two coupon days of a half-yearly security, e.g. "14 Feb / 14 Aug". */
|
||||||
|
export function couponDatesLabel(maturityStr: string): string {
|
||||||
|
const mat = parseDDMMYYYY(maturityStr);
|
||||||
|
if (isNaN(mat.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const label = (month: number): string => {
|
||||||
|
const day = Math.min(mat.getDate(), new Date(2001, month + 1, 0).getDate());
|
||||||
|
return `${day} ${new Date(2001, month, 1).toLocaleString('en-US', { month: 'short' })}`;
|
||||||
|
};
|
||||||
|
const first = mat.getMonth() % 6;
|
||||||
|
return `${label(first)} / ${label(first + 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDDMMYYYY(d: Date): string {
|
export function formatDDMMYYYY(d: Date): string {
|
||||||
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
return `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
|
||||||
}
|
}
|
||||||
@@ -1404,99 +1495,405 @@ export const CASH_BANK_GL_MASTER: InvestmentGlOption[] = [
|
|||||||
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
|
{ glCode: '110030', glName: 'HDFC BANK CURRENT ACCOUNT' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Investment GLs a script / party can be mapped to. PROPOSED codes awaiting approval — the
|
||||||
|
* backend will take these from the chart of accounts, never hard-coded. */
|
||||||
|
export const INVESTMENT_GL_OPTIONS: InvestmentGlOption[] = [
|
||||||
|
{ glCode: '100701', glName: 'INVESTMENT IN CENTRAL GOVT SECURITIES' },
|
||||||
|
{ glCode: '100702', glName: 'INVESTMENT IN STATE GOVT SECURITIES' },
|
||||||
|
{ glCode: '100703', glName: 'INVESTMENT IN BONDS / DEBENTURES' },
|
||||||
|
{ glCode: '100704', glName: 'FIXED DEPOSITS WITH OTHER BANKS' },
|
||||||
|
{ glCode: '100705', glName: 'INVESTMENT IN MUTUAL FUNDS' },
|
||||||
|
{ glCode: '100706', glName: 'INVESTMENT IN SHARES' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Fallback investment GL when a script has none mapped. */
|
||||||
|
export const INVESTMENT_GL_BY_TYPE: Record<InstrumentType, string> = {
|
||||||
|
GOVT_SECURITY: '100701',
|
||||||
|
NON_SLR: '100703',
|
||||||
|
BOND: '100703',
|
||||||
|
FD: '100704',
|
||||||
|
MUTUAL_FUND: '100705',
|
||||||
|
SHARE: '100706'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Broken-period interest paid to the seller at purchase — an expense, not part of cost (RBI UCB Investment
|
||||||
|
* Directions para 152). */
|
||||||
|
export const BROKEN_PERIOD_INTEREST_GL: InvestmentGlOption = {
|
||||||
|
glCode: '500606',
|
||||||
|
glName: 'BROKEN PERIOD INTEREST PAID ON INVESTMENTS'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Charges on an investment deal — expenses on their own GLs, not part of the book value (Jitendra Sir). */
|
||||||
|
export const STAMP_DUTY_GL: InvestmentGlOption = { glCode: '500601', glName: 'STAMP DUTY ON INVESTMENTS' };
|
||||||
|
export const BROKERAGE_GL: InvestmentGlOption = { glCode: '500602', glName: 'BROKERAGE ON INVESTMENTS' };
|
||||||
|
export const OTHER_CHARGES_GL: InvestmentGlOption = { glCode: '500603', glName: 'OTHER CHARGES ON INVESTMENTS' };
|
||||||
|
/** Premium paid over face value, held until amortised to maturity (Jitendra Sir). */
|
||||||
|
export const PREMIUM_ON_INVESTMENTS_GL: InvestmentGlOption = { glCode: '100707', glName: 'PREMIUM ON INVESTMENTS' };
|
||||||
|
|
||||||
|
/** Interest receivable (the senior's "Provision A/c") — debited for interest bought from the seller. */
|
||||||
|
export const INTEREST_RECEIVABLE_GL: InvestmentGlOption = {
|
||||||
|
glCode: '100603',
|
||||||
|
glName: 'INTEREST RECEIVABLE ON INVESTMENTS (PROVISION)'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bank accounts a purchase can be paid from — these GLs already exist in the chart of accounts. */
|
||||||
|
export const SETTLEMENT_BANK_GL_OPTIONS: InvestmentGlOption[] = [
|
||||||
|
{ glCode: '100201', glName: 'IDBI BANK' },
|
||||||
|
{ glCode: '100202', glName: 'YES BANK' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Every GL the Investment screens can show by name. */
|
||||||
|
export const INVESTMENT_GL_LOOKUP: InvestmentGlOption[] = [
|
||||||
|
...INVESTMENT_GL_OPTIONS,
|
||||||
|
INTEREST_RECEIVABLE_GL,
|
||||||
|
...SETTLEMENT_BANK_GL_OPTIONS
|
||||||
|
];
|
||||||
|
|
||||||
|
/** SLR classification on the Script Master, as in the bank's existing CBS ("GOI" = Central Govt). */
|
||||||
|
export type SlrType = 'GOI' | 'STATE_GOVT' | 'TREASURY' | 'NON_SLR';
|
||||||
|
|
||||||
|
export const SLR_TYPE_OPTIONS: Array<{ value: SlrType; label: string }> = [
|
||||||
|
{ value: 'GOI', label: 'GOI' },
|
||||||
|
{ value: 'STATE_GOVT', label: 'STATE GOVT' },
|
||||||
|
{ value: 'TREASURY', label: 'TREASURY' },
|
||||||
|
{ value: 'NON_SLR', label: 'NON SLR' }
|
||||||
|
];
|
||||||
|
|
||||||
export interface ScriptMaster {
|
export interface ScriptMaster {
|
||||||
|
/** Backend id once the script is saved (absent on the sample rows). */
|
||||||
|
id?: number;
|
||||||
scriptCode: string;
|
scriptCode: string;
|
||||||
scriptName: string;
|
scriptName: string;
|
||||||
instrumentType: InstrumentType;
|
instrumentType: InstrumentType;
|
||||||
isin: string;
|
isin: string;
|
||||||
faceValuePerUnit: number;
|
faceValuePerUnit: number;
|
||||||
|
// Fields below follow the bank's existing CBS Script Master; a purchase picks them up instead
|
||||||
|
// of the dealer re-typing them on every deal.
|
||||||
|
slrType?: SlrType;
|
||||||
|
/** Investment GL this script is booked to. */
|
||||||
|
glAccountId?: number;
|
||||||
|
glCode?: string;
|
||||||
|
glName?: string;
|
||||||
|
active?: boolean;
|
||||||
|
/** Interest (coupon) rate % per annum. */
|
||||||
|
couponRate?: number;
|
||||||
|
issueDate?: string;
|
||||||
|
maturityDate?: string;
|
||||||
|
// Issuer details — used for Bond / Mutual Fund scripts.
|
||||||
|
companyName?: string;
|
||||||
|
address1?: string;
|
||||||
|
address2?: string;
|
||||||
|
address3?: string;
|
||||||
|
city?: string;
|
||||||
|
pincode?: string;
|
||||||
|
phone?: string;
|
||||||
|
fax?: string;
|
||||||
|
corpCode?: string;
|
||||||
|
/** Credit rating — long-term for bonds, short-term for CP / CD (Non-SLR and Bond scripts). */
|
||||||
|
rating?: string;
|
||||||
|
ratingAgency?: string;
|
||||||
|
/** Listed on an exchange or proposed to be listed; unlisted ones have their own RBI limit. */
|
||||||
|
listed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Credit ratings on the Script Master, best first: long-term for bonds, short-term for CP / CD. */
|
||||||
|
export const RATING_OPTIONS: Array<{ group: string; values: string[] }> = [
|
||||||
|
{
|
||||||
|
group: 'Long-term (bonds)',
|
||||||
|
values: [
|
||||||
|
'AAA',
|
||||||
|
'AA+',
|
||||||
|
'AA',
|
||||||
|
'AA-',
|
||||||
|
'A+',
|
||||||
|
'A',
|
||||||
|
'A-',
|
||||||
|
'BBB+',
|
||||||
|
'BBB',
|
||||||
|
'BBB-',
|
||||||
|
'BB+',
|
||||||
|
'BB',
|
||||||
|
'BB-',
|
||||||
|
'B+',
|
||||||
|
'B',
|
||||||
|
'B-',
|
||||||
|
'C',
|
||||||
|
'D'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Short-term (CP / CD)',
|
||||||
|
values: [
|
||||||
|
'A1+',
|
||||||
|
'A1',
|
||||||
|
'A2+',
|
||||||
|
'A2',
|
||||||
|
'A3+',
|
||||||
|
'A3',
|
||||||
|
'A4+',
|
||||||
|
'A4'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ group: 'None', values: ['UNRATED'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** RBI: bonds rated "A" or higher, CP / CD rated "A2" or higher. */
|
||||||
|
export const ELIGIBLE_RATINGS: string[] = [
|
||||||
|
'AAA',
|
||||||
|
'AA+',
|
||||||
|
'AA',
|
||||||
|
'AA-',
|
||||||
|
'A+',
|
||||||
|
'A',
|
||||||
|
'A-',
|
||||||
|
'A1+',
|
||||||
|
'A1',
|
||||||
|
'A2+',
|
||||||
|
'A2'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Credit rating agencies registered with SEBI. */
|
||||||
|
export const RATING_AGENCY_OPTIONS: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: 'CRISIL', label: 'CRISIL' },
|
||||||
|
{ value: 'ICRA', label: 'ICRA' },
|
||||||
|
{ value: 'CARE', label: 'CARE' },
|
||||||
|
{ value: 'INDIA_RATINGS', label: 'India Ratings' },
|
||||||
|
{ value: 'ACUITE', label: 'Acuite' },
|
||||||
|
{ value: 'BRICKWORK', label: 'Brickwork' },
|
||||||
|
{ value: 'INFOMERICS', label: 'Infomerics' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Period between two DD-MM-YYYY dates as years / months / days, as shown on the Script Master. */
|
||||||
|
export function periodBetween(fromStr?: string, toStr?: string): { years: number; months: number; days: number } {
|
||||||
|
const none = { years: 0, months: 0, days: 0 };
|
||||||
|
if (!fromStr || !toStr) {
|
||||||
|
return none;
|
||||||
|
}
|
||||||
|
const from = parseDDMMYYYY(fromStr);
|
||||||
|
const to = parseDDMMYYYY(toStr);
|
||||||
|
if (isNaN(from.getTime()) || isNaN(to.getTime()) || to < from) {
|
||||||
|
return none;
|
||||||
|
}
|
||||||
|
let years = to.getFullYear() - from.getFullYear();
|
||||||
|
let months = to.getMonth() - from.getMonth();
|
||||||
|
let days = to.getDate() - from.getDate();
|
||||||
|
if (days < 0) {
|
||||||
|
months -= 1;
|
||||||
|
days += new Date(to.getFullYear(), to.getMonth(), 0).getDate();
|
||||||
|
}
|
||||||
|
if (months < 0) {
|
||||||
|
years -= 1;
|
||||||
|
months += 12;
|
||||||
|
}
|
||||||
|
return { years, months, days };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SCRIPT_MASTER: ScriptMaster[] = [
|
export const SCRIPT_MASTER: ScriptMaster[] = [
|
||||||
{ scriptCode: 'FD-CUM', scriptName: 'Cumulative Fixed Deposit', instrumentType: 'FD', isin: '', faceValuePerUnit: 1 },
|
{
|
||||||
|
scriptCode: 'FD-CUM',
|
||||||
|
scriptName: 'Cumulative Fixed Deposit',
|
||||||
|
instrumentType: 'FD',
|
||||||
|
isin: '',
|
||||||
|
faceValuePerUnit: 1,
|
||||||
|
slrType: 'NON_SLR',
|
||||||
|
glCode: '100704'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'FD-MIP',
|
scriptCode: 'FD-MIP',
|
||||||
scriptName: 'Monthly Interest Payout FD',
|
scriptName: 'Monthly Interest Payout FD',
|
||||||
instrumentType: 'FD',
|
instrumentType: 'FD',
|
||||||
isin: '',
|
isin: '',
|
||||||
faceValuePerUnit: 1
|
faceValuePerUnit: 1,
|
||||||
|
slrType: 'NON_SLR',
|
||||||
|
glCode: '100704'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'IN0020230012',
|
scriptCode: 'IN0020230012',
|
||||||
scriptName: '7.26% GOI 2033',
|
scriptName: '7.26% GOI 2033',
|
||||||
instrumentType: 'GOVT_SECURITY',
|
instrumentType: 'GOVT_SECURITY',
|
||||||
isin: 'IN0020230012',
|
isin: 'IN0020230012',
|
||||||
faceValuePerUnit: 100
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'GOI',
|
||||||
|
glCode: '100701',
|
||||||
|
couponRate: 7.26,
|
||||||
|
issueDate: '15-01-2023',
|
||||||
|
maturityDate: '15-01-2033'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'IN0020220089',
|
scriptCode: 'IN0020220089',
|
||||||
scriptName: '7.10% GOI 2029',
|
scriptName: '7.10% GOI 2029',
|
||||||
instrumentType: 'GOVT_SECURITY',
|
instrumentType: 'GOVT_SECURITY',
|
||||||
isin: 'IN0020220089',
|
isin: 'IN0020220089',
|
||||||
faceValuePerUnit: 100
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'GOI',
|
||||||
|
glCode: '100701',
|
||||||
|
couponRate: 7.1,
|
||||||
|
issueDate: '10-03-2022',
|
||||||
|
maturityDate: '10-03-2029'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scriptCode: 'IN0020210056',
|
||||||
|
scriptName: '6.79% GOI 2027',
|
||||||
|
instrumentType: 'GOVT_SECURITY',
|
||||||
|
isin: 'IN0020210056',
|
||||||
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'GOI',
|
||||||
|
glCode: '100701',
|
||||||
|
couponRate: 6.79,
|
||||||
|
issueDate: '05-06-2021',
|
||||||
|
maturityDate: '05-06-2027'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scriptCode: 'IN0020240034',
|
||||||
|
scriptName: '7.18% GOI 2037',
|
||||||
|
instrumentType: 'GOVT_SECURITY',
|
||||||
|
isin: 'IN0020240034',
|
||||||
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'GOI',
|
||||||
|
glCode: '100701',
|
||||||
|
couponRate: 7.18,
|
||||||
|
issueDate: '22-04-2024',
|
||||||
|
maturityDate: '22-04-2037'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scriptCode: 'MH-SDL-2031',
|
||||||
|
scriptName: '8.05% Maharashtra SDL 2031',
|
||||||
|
instrumentType: 'GOVT_SECURITY',
|
||||||
|
isin: '',
|
||||||
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'STATE_GOVT',
|
||||||
|
glCode: '100702',
|
||||||
|
couponRate: 8.05,
|
||||||
|
issueDate: '15-02-2021',
|
||||||
|
maturityDate: '15-02-2031'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scriptCode: 'GJ-SDL-2026',
|
||||||
|
scriptName: '7.85% Gujarat SDL 2026',
|
||||||
|
instrumentType: 'GOVT_SECURITY',
|
||||||
|
isin: '',
|
||||||
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'STATE_GOVT',
|
||||||
|
glCode: '100702',
|
||||||
|
couponRate: 7.85,
|
||||||
|
issueDate: '18-09-2016',
|
||||||
|
maturityDate: '18-09-2026'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'TBILL-364D',
|
scriptCode: 'TBILL-364D',
|
||||||
scriptName: '364 Days Treasury Bill',
|
scriptName: '364 Days Treasury Bill',
|
||||||
instrumentType: 'GOVT_SECURITY',
|
instrumentType: 'GOVT_SECURITY',
|
||||||
isin: '',
|
isin: '',
|
||||||
faceValuePerUnit: 100
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'TREASURY',
|
||||||
|
glCode: '100701',
|
||||||
|
couponRate: 0,
|
||||||
|
issueDate: '02-07-2026',
|
||||||
|
maturityDate: '02-07-2027'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scriptCode: 'TBILL-91D',
|
||||||
|
scriptName: '91 Days Treasury Bill',
|
||||||
|
instrumentType: 'GOVT_SECURITY',
|
||||||
|
isin: '',
|
||||||
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'TREASURY',
|
||||||
|
glCode: '100701',
|
||||||
|
couponRate: 0,
|
||||||
|
issueDate: '15-05-2026',
|
||||||
|
maturityDate: '13-08-2026'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'REC-7.5-2031',
|
scriptCode: 'REC-7.5-2031',
|
||||||
scriptName: 'REC Ltd Bond 7.50% 2031',
|
scriptName: 'REC Ltd Bond 7.50% 2031',
|
||||||
instrumentType: 'NON_SLR',
|
instrumentType: 'NON_SLR',
|
||||||
isin: 'INE020B08JN0',
|
isin: 'INE020B08JN0',
|
||||||
faceValuePerUnit: 100
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'NON_SLR',
|
||||||
|
glCode: '100703',
|
||||||
|
couponRate: 7.5,
|
||||||
|
issueDate: '11-08-2021',
|
||||||
|
maturityDate: '11-08-2031',
|
||||||
|
companyName: 'REC Limited',
|
||||||
|
city: 'New Delhi'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'NABARD-7.35-2030',
|
scriptCode: 'NABARD-7.35-2030',
|
||||||
scriptName: 'NABARD Bond 7.35% 2030',
|
scriptName: 'NABARD Bond 7.35% 2030',
|
||||||
instrumentType: 'NON_SLR',
|
instrumentType: 'NON_SLR',
|
||||||
isin: 'INE261F09EU1',
|
isin: 'INE261F09EU1',
|
||||||
faceValuePerUnit: 100
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'NON_SLR',
|
||||||
|
glCode: '100703',
|
||||||
|
couponRate: 7.35,
|
||||||
|
issueDate: '20-09-2020',
|
||||||
|
maturityDate: '20-09-2030',
|
||||||
|
companyName: 'NABARD',
|
||||||
|
city: 'Mumbai'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'LTF-8.1-2028',
|
scriptCode: 'LTF-8.1-2028',
|
||||||
scriptName: 'L&T Finance NCD 8.10% 2028',
|
scriptName: 'L&T Finance NCD 8.10% 2028',
|
||||||
instrumentType: 'BOND',
|
instrumentType: 'BOND',
|
||||||
isin: 'INE027E07BR5',
|
isin: 'INE027E07BR5',
|
||||||
faceValuePerUnit: 100
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'NON_SLR',
|
||||||
|
glCode: '100703',
|
||||||
|
couponRate: 8.1,
|
||||||
|
companyName: 'L&T Finance Ltd',
|
||||||
|
city: 'Mumbai'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'BAF-7.95-2027',
|
scriptCode: 'BAF-7.95-2027',
|
||||||
scriptName: 'Bajaj Finance NCD 7.95% 2027',
|
scriptName: 'Bajaj Finance NCD 7.95% 2027',
|
||||||
instrumentType: 'BOND',
|
instrumentType: 'BOND',
|
||||||
isin: 'INE296A07SP0',
|
isin: 'INE296A07SP0',
|
||||||
faceValuePerUnit: 100
|
faceValuePerUnit: 100,
|
||||||
|
slrType: 'NON_SLR',
|
||||||
|
glCode: '100703',
|
||||||
|
couponRate: 7.95,
|
||||||
|
companyName: 'Bajaj Finance Ltd',
|
||||||
|
city: 'Pune'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'SBI-OVN',
|
scriptCode: 'SBI-OVN',
|
||||||
scriptName: 'SBI Overnight Fund',
|
scriptName: 'SBI Overnight Fund',
|
||||||
instrumentType: 'MUTUAL_FUND',
|
instrumentType: 'MUTUAL_FUND',
|
||||||
isin: 'INF200KA1Q40',
|
isin: 'INF200KA1Q40',
|
||||||
faceValuePerUnit: 1000
|
faceValuePerUnit: 1000,
|
||||||
|
slrType: 'NON_SLR',
|
||||||
|
glCode: '100705',
|
||||||
|
companyName: 'SBI Mutual Fund',
|
||||||
|
city: 'Mumbai'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'HDFC-LIQ',
|
scriptCode: 'HDFC-LIQ',
|
||||||
scriptName: 'HDFC Liquid Fund',
|
scriptName: 'HDFC Liquid Fund',
|
||||||
instrumentType: 'MUTUAL_FUND',
|
instrumentType: 'MUTUAL_FUND',
|
||||||
isin: 'INF179KA1WR9',
|
isin: 'INF179KA1WR9',
|
||||||
faceValuePerUnit: 1000
|
faceValuePerUnit: 1000,
|
||||||
|
slrType: 'NON_SLR',
|
||||||
|
glCode: '100705',
|
||||||
|
companyName: 'HDFC Mutual Fund',
|
||||||
|
city: 'Mumbai'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'SBIN',
|
scriptCode: 'SBIN',
|
||||||
scriptName: 'State Bank of India',
|
scriptName: 'State Bank of India',
|
||||||
instrumentType: 'SHARE',
|
instrumentType: 'SHARE',
|
||||||
isin: 'INE062A01020',
|
isin: 'INE062A01020',
|
||||||
faceValuePerUnit: 1
|
faceValuePerUnit: 1,
|
||||||
|
slrType: 'NON_SLR',
|
||||||
|
glCode: '100706'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
scriptCode: 'HDFCBANK',
|
scriptCode: 'HDFCBANK',
|
||||||
scriptName: 'HDFC Bank Ltd',
|
scriptName: 'HDFC Bank Ltd',
|
||||||
instrumentType: 'SHARE',
|
instrumentType: 'SHARE',
|
||||||
isin: 'INE040A01034',
|
isin: 'INE040A01034',
|
||||||
faceValuePerUnit: 1
|
faceValuePerUnit: 1,
|
||||||
|
slrType: 'NON_SLR',
|
||||||
|
glCode: '100706'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1561,23 +1958,157 @@ export const BROKER_MASTER: BrokerMaster[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export interface PartyMaster {
|
export interface PartyMaster {
|
||||||
|
/** Backend id once the party is saved (absent on the sample rows). */
|
||||||
|
id?: number;
|
||||||
partyCode: string;
|
partyCode: string;
|
||||||
partyName: string;
|
partyName: string;
|
||||||
partyType: 'BANK' | 'CORPORATE' | 'GOVT' | 'AMC' | 'EXCHANGE' | 'COOP_SOCIETY';
|
partyType: 'BANK' | 'CORPORATE' | 'GOVT' | 'AMC' | 'EXCHANGE' | 'COOP_SOCIETY';
|
||||||
panNo: string;
|
panNo?: string;
|
||||||
|
// Fields below follow the bank's existing CBS Bank / Party Master.
|
||||||
|
bondType?: InstrumentType;
|
||||||
|
address1?: string;
|
||||||
|
address2?: string;
|
||||||
|
address3?: string;
|
||||||
|
city?: string;
|
||||||
|
pincode?: string;
|
||||||
|
phone?: string;
|
||||||
|
fax?: string;
|
||||||
|
bankType?: string;
|
||||||
|
/** GL this party is mapped to. */
|
||||||
|
glAccountId?: number;
|
||||||
|
glCode?: string;
|
||||||
|
glName?: string;
|
||||||
|
/** Interest income GL of the FDs placed with this bank (Bond Type FD). */
|
||||||
|
interestGlAccountId?: number;
|
||||||
|
interestGlCode?: string;
|
||||||
|
interestGlName?: string;
|
||||||
|
/** Whether this bank deducts TDS from FD interest (some do, some do not), and at what rate (%). */
|
||||||
|
tdsApplicable?: boolean;
|
||||||
|
tdsRate?: number | null;
|
||||||
|
rbiCode?: string;
|
||||||
|
/** Exposure limit for this party (₹). */
|
||||||
|
limit?: number | null;
|
||||||
|
leiNo?: string;
|
||||||
|
// Counterparty's bank details
|
||||||
|
cpBankName?: string;
|
||||||
|
cpBranchName?: string;
|
||||||
|
cpAccountNo?: string;
|
||||||
|
cpIfsc?: string;
|
||||||
|
/** Absent means ACTIVE. An INACTIVE party is not offered for new deals. */
|
||||||
|
status?: 'ACTIVE' | 'INACTIVE';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sample Bank Type options until the bank confirms its list. */
|
||||||
|
export const BANK_TYPE_OPTIONS = [
|
||||||
|
'SCHEDULED COMMERCIAL BANK',
|
||||||
|
'STATE CO-OPERATIVE BANK',
|
||||||
|
'DISTRICT CENTRAL CO-OPERATIVE BANK',
|
||||||
|
'URBAN CO-OPERATIVE BANK',
|
||||||
|
'RBI / GOVERNMENT',
|
||||||
|
'OTHER'
|
||||||
|
];
|
||||||
|
|
||||||
export const PARTY_MASTER: PartyMaster[] = [
|
export const PARTY_MASTER: PartyMaster[] = [
|
||||||
{ partyCode: 'PTY-001', partyName: 'Jila Sahakari Kendriya Bank', partyType: 'BANK', panNo: 'AABCJ1234K' },
|
{
|
||||||
{ partyCode: 'PTY-002', partyName: 'State Cooperative Bank Ltd', partyType: 'BANK', panNo: 'AABCS5678L' },
|
partyCode: 'PTY-001',
|
||||||
{ partyCode: 'PTY-003', partyName: 'RBI - SGL', partyType: 'GOVT', panNo: 'AAACR4849R' },
|
partyName: 'Jila Sahakari Kendriya Bank',
|
||||||
{ partyCode: 'PTY-004', partyName: 'REC Limited', partyType: 'CORPORATE', panNo: 'AAACR0641G' },
|
partyType: 'BANK',
|
||||||
{ partyCode: 'PTY-005', partyName: 'NABARD', partyType: 'CORPORATE', panNo: 'AAACN0272B' },
|
bondType: 'FD',
|
||||||
{ partyCode: 'PTY-006', partyName: 'L&T Finance Ltd', partyType: 'CORPORATE', panNo: 'AAACL0140P' },
|
glCode: '100704',
|
||||||
{ partyCode: 'PTY-007', partyName: 'SBI Mutual Fund', partyType: 'AMC', panNo: 'AAATS4325L' },
|
city: 'Vidisha',
|
||||||
{ partyCode: 'PTY-008', partyName: 'HDFC Mutual Fund', partyType: 'AMC', panNo: 'AAATH1234M' },
|
bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
|
||||||
{ partyCode: 'PTY-009', partyName: 'NSE/BSE', partyType: 'EXCHANGE', panNo: 'AAACN0028G' },
|
status: 'ACTIVE'
|
||||||
{ partyCode: 'PTY-010', partyName: 'District Cooperative Bank', partyType: 'COOP_SOCIETY', panNo: 'AABCD9988K' }
|
},
|
||||||
|
{
|
||||||
|
partyCode: 'PTY-002',
|
||||||
|
partyName: 'State Cooperative Bank Ltd',
|
||||||
|
partyType: 'BANK',
|
||||||
|
bondType: 'FD',
|
||||||
|
glCode: '100704',
|
||||||
|
city: 'Bhopal',
|
||||||
|
bankType: 'STATE CO-OPERATIVE BANK',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partyCode: 'PTY-003',
|
||||||
|
partyName: 'RBI - SGL',
|
||||||
|
partyType: 'GOVT',
|
||||||
|
bondType: 'GOVT_SECURITY',
|
||||||
|
glCode: '100701',
|
||||||
|
city: 'Mumbai',
|
||||||
|
bankType: 'RBI / GOVERNMENT',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partyCode: 'PTY-004',
|
||||||
|
partyName: 'REC Limited',
|
||||||
|
partyType: 'CORPORATE',
|
||||||
|
bondType: 'NON_SLR',
|
||||||
|
glCode: '100703',
|
||||||
|
city: 'New Delhi',
|
||||||
|
bankType: 'OTHER',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partyCode: 'PTY-005',
|
||||||
|
partyName: 'NABARD',
|
||||||
|
partyType: 'CORPORATE',
|
||||||
|
bondType: 'NON_SLR',
|
||||||
|
glCode: '100703',
|
||||||
|
city: 'Mumbai',
|
||||||
|
bankType: 'OTHER',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partyCode: 'PTY-006',
|
||||||
|
partyName: 'L&T Finance Ltd',
|
||||||
|
partyType: 'CORPORATE',
|
||||||
|
bondType: 'BOND',
|
||||||
|
glCode: '100703',
|
||||||
|
city: 'Mumbai',
|
||||||
|
bankType: 'OTHER',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partyCode: 'PTY-007',
|
||||||
|
partyName: 'SBI Mutual Fund',
|
||||||
|
partyType: 'AMC',
|
||||||
|
bondType: 'MUTUAL_FUND',
|
||||||
|
glCode: '100705',
|
||||||
|
city: 'Mumbai',
|
||||||
|
bankType: 'OTHER',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partyCode: 'PTY-008',
|
||||||
|
partyName: 'HDFC Mutual Fund',
|
||||||
|
partyType: 'AMC',
|
||||||
|
bondType: 'MUTUAL_FUND',
|
||||||
|
glCode: '100705',
|
||||||
|
city: 'Mumbai',
|
||||||
|
bankType: 'OTHER',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partyCode: 'PTY-009',
|
||||||
|
partyName: 'NSE/BSE',
|
||||||
|
partyType: 'EXCHANGE',
|
||||||
|
bondType: 'SHARE',
|
||||||
|
glCode: '100706',
|
||||||
|
city: 'Mumbai',
|
||||||
|
bankType: 'OTHER',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
partyCode: 'PTY-010',
|
||||||
|
partyName: 'District Cooperative Bank',
|
||||||
|
partyType: 'COOP_SOCIETY',
|
||||||
|
bondType: 'SHARE',
|
||||||
|
glCode: '100706',
|
||||||
|
city: 'Vidisha',
|
||||||
|
bankType: 'DISTRICT CENTRAL CO-OPERATIVE BANK',
|
||||||
|
status: 'ACTIVE'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface SubTypeMaster {
|
export interface SubTypeMaster {
|
||||||
@@ -1593,7 +2124,11 @@ export const SUBTYPE_MASTER: SubTypeMaster[] = [
|
|||||||
{ subTypeCode: '4', subTypeName: 'EQUITY', instrumentType: 'SHARE' },
|
{ subTypeCode: '4', subTypeName: 'EQUITY', instrumentType: 'SHARE' },
|
||||||
{ subTypeCode: '5', subTypeName: 'CO-OP SHARE', instrumentType: 'SHARE' },
|
{ subTypeCode: '5', subTypeName: 'CO-OP SHARE', instrumentType: 'SHARE' },
|
||||||
{ subTypeCode: '6', subTypeName: 'DEBT - OVERNIGHT', instrumentType: 'MUTUAL_FUND' },
|
{ subTypeCode: '6', subTypeName: 'DEBT - OVERNIGHT', instrumentType: 'MUTUAL_FUND' },
|
||||||
{ subTypeCode: '7', subTypeName: 'DEBT - LIQUID', instrumentType: 'MUTUAL_FUND' }
|
{ subTypeCode: '7', subTypeName: 'DEBT - LIQUID', instrumentType: 'MUTUAL_FUND' },
|
||||||
|
{ subTypeCode: '8', subTypeName: 'DEBT - MONEY MARKET', instrumentType: 'MUTUAL_FUND' },
|
||||||
|
{ subTypeCode: '9', subTypeName: 'DEBT - BANKING & PSU', instrumentType: 'MUTUAL_FUND' },
|
||||||
|
{ subTypeCode: '10', subTypeName: 'DEBT - CORPORATE BOND', instrumentType: 'MUTUAL_FUND' },
|
||||||
|
{ subTypeCode: '11', subTypeName: 'DEBT - GILT', instrumentType: 'MUTUAL_FUND' }
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ControlParameter {
|
export interface ControlParameter {
|
||||||
@@ -1725,13 +2260,15 @@ export const LIEN_REASON_MASTER: LienReason[] = [
|
|||||||
/** Daily Script Master (legacy menu "09") — the day's quoted rate per script, entered/imported
|
/** Daily Script Master (legacy menu "09") — the day's quoted rate per script, entered/imported
|
||||||
* every working day. Distinct from Script Market Detail ("13") which also carries YTM/valuation. */
|
* every working day. Distinct from Script Market Detail ("13") which also carries YTM/valuation. */
|
||||||
export interface DailyScriptRate {
|
export interface DailyScriptRate {
|
||||||
|
/** Backend id once saved. */
|
||||||
|
id?: number;
|
||||||
rateDate: string;
|
rateDate: string;
|
||||||
scriptCode: string;
|
scriptCode: string;
|
||||||
scriptName: string;
|
scriptName: string;
|
||||||
openRate: number;
|
openRate: number | null;
|
||||||
closeRate: number;
|
closeRate: number;
|
||||||
highRate: number;
|
highRate: number | null;
|
||||||
lowRate: number;
|
lowRate: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DAILY_SCRIPT_RATES: DailyScriptRate[] = [
|
export const DAILY_SCRIPT_RATES: DailyScriptRate[] = [
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!--
|
||||||
|
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">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">TREASURY · INVESTMENT · DUE DATE DIARY</div>
|
||||||
|
<h1 class="pg-title">Due Date Diary</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Coupons, 31 Mar / 30 Sep provisions, premium amortisation, maturities and FD interest due in a period, with what
|
||||||
|
is posted and what is still pending.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="inv-form-grid">
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>From</span><mifosx-date-ddmmyyyy [(ngModel)]="fromDate"></mifosx-date-ddmmyyyy
|
||||||
|
></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>To</span><mifosx-date-ddmmyyyy [(ngModel)]="toDate"></mifosx-date-ddmmyyyy
|
||||||
|
></label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Investment</span>
|
||||||
|
<select [(ngModel)]="instrument">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="SECURITY">Govt Security / Bond</option>
|
||||||
|
<option value="FD">FD with banks</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="status">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="PENDING">Pending</option>
|
||||||
|
<option value="POSTED">Posted</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="diary-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="setRange(7)">Next 7 days</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="setRange(30)">Next 30 days</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="setRange(90)">Next 90 days</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="showOverdue()">Overdue</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="loading" (click)="load()">
|
||||||
|
{{ loading ? 'Loading…' : 'Show' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (error) {
|
||||||
|
<p class="diary-error">{{ error }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (diary; as d) {
|
||||||
|
<div class="diary-summary">
|
||||||
|
<div class="diary-tile">
|
||||||
|
<span class="diary-tile-l">Entries</span><span class="diary-tile-v">{{ rows.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diary-tile">
|
||||||
|
<span class="diary-tile-l">Pending</span><span class="diary-tile-v">{{ d.pendingCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diary-tile" [class.diary-tile--bad]="d.overdueCount > 0">
|
||||||
|
<span class="diary-tile-l">Overdue</span><span class="diary-tile-v">{{ d.overdueCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diary-tile">
|
||||||
|
<span class="diary-tile-l">Money expected (not yet received)</span
|
||||||
|
><span class="diary-tile-v">₹{{ d.expectedInflow | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">{{ d.fromDate }} to {{ d.toDate }} · as on {{ d.asOnDate }}</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Due</th>
|
||||||
|
<th>Investment</th>
|
||||||
|
<th>Deal No</th>
|
||||||
|
<th>Particulars</th>
|
||||||
|
<th class="num">Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rows; track r.instrument + r.refId + r.kind + r.date) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.date }}</td>
|
||||||
|
<td>{{ kindLabels[r.kind] }}</td>
|
||||||
|
<td>{{ r.instrument === 'FD' ? 'FD' : 'Govt Security / Bond' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ r.dealNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.particulars }}</td>
|
||||||
|
<td class="num">
|
||||||
|
{{ r.amount !== null && r.amount !== undefined ? '₹' + (r.amount | number: '1.2-2') : '—' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="inv-badge" [ngClass]="'diary-badge--' + r.status.toLowerCase()">{{ statusLabel(r) }}</span>
|
||||||
|
@if (r.voucherNo) {
|
||||||
|
<br /><small>{{ r.voucherNo }}</small>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (r.status !== 'POSTED') {
|
||||||
|
<a class="diary-open" [routerLink]="link(r).path" [queryParams]="link(r).query">Open →</a>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="pg-empty">Nothing falls due in this period.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.diary-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diary-error {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diary-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diary-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.diary-tile-l {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diary-tile-v {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diary-badge--posted {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diary-badge--due {
|
||||||
|
background: #fff7ed;
|
||||||
|
color: #c2410c;
|
||||||
|
border: 1px solid #fed7aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diary-badge--overdue {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #b91c1c;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diary-badge--upcoming {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diary-open {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-total td {
|
||||||
|
font-weight: 700;
|
||||||
|
border-top: 2px solid #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-pick {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&--on td {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||||
|
import { addPeriod, todayDDMMYYYY } from '../investment-data';
|
||||||
|
import { InvestmentDiaryDto, InvestmentDiaryRowDto, InvestmentService, apiErrorMessage } from '../investment.service';
|
||||||
|
|
||||||
|
type InstrumentFilter = '' | 'SECURITY' | 'FD';
|
||||||
|
type StatusFilter = '' | 'PENDING' | 'POSTED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Due Date Diary for All Investment: coupons, 31 Mar / 30 Sep provisions, premium amortisation, maturities and FD
|
||||||
|
* payouts in a period, with what is posted, due or overdue, and a link to the screen that posts each one.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-investment-diary',
|
||||||
|
templateUrl: './investment-diary.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../investment-shared.scss',
|
||||||
|
'./investment-diary.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterModule,
|
||||||
|
DateDdmmyyyyPickerComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class InvestmentDiaryComponent implements OnInit {
|
||||||
|
private readonly investmentService = inject(InvestmentService);
|
||||||
|
|
||||||
|
// Opens on everything still to be done: what is pending from the past year and what falls due in the next 30 days.
|
||||||
|
fromDate = addPeriod(todayDDMMYYYY(), -1, 0, 0);
|
||||||
|
toDate = addPeriod(todayDDMMYYYY(), 0, 0, 30);
|
||||||
|
instrument: InstrumentFilter = '';
|
||||||
|
status: StatusFilter = 'PENDING';
|
||||||
|
diary: InvestmentDiaryDto | null = null;
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
readonly kindLabels: Record<InvestmentDiaryRowDto['kind'], string> = {
|
||||||
|
COUPON: 'Coupon',
|
||||||
|
PROVISION: 'Interest provision',
|
||||||
|
MATURITY: 'Maturity / redemption',
|
||||||
|
AMORTISATION: 'Premium amortisation',
|
||||||
|
FD_PROVISION: 'FD interest provision',
|
||||||
|
FD_PAYOUT: 'FD interest payout',
|
||||||
|
FD_MATURITY: 'FD maturity'
|
||||||
|
};
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quick ranges from today. */
|
||||||
|
setRange(days: number): void {
|
||||||
|
this.status = '';
|
||||||
|
this.fromDate = todayDDMMYYYY();
|
||||||
|
this.toDate = addPeriod(this.fromDate, 0, 0, days);
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What is still pending from the past (up to a year back) and today. */
|
||||||
|
showOverdue(): void {
|
||||||
|
this.toDate = todayDDMMYYYY();
|
||||||
|
this.fromDate = addPeriod(this.toDate, -1, 0, 0);
|
||||||
|
this.status = 'PENDING';
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
if (!this.fromDate || !this.toDate) {
|
||||||
|
this.error = 'Enter the From and To dates.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.investmentService.getDiary(this.fromDate, this.toDate).subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
this.diary = d;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.diary = null;
|
||||||
|
this.loading = false;
|
||||||
|
this.error = apiErrorMessage(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get rows(): InvestmentDiaryRowDto[] {
|
||||||
|
return (this.diary?.rows ?? []).filter(
|
||||||
|
(r) =>
|
||||||
|
(!this.instrument || r.instrument === this.instrument) &&
|
||||||
|
(!this.status || (this.status === 'POSTED' ? r.status === 'POSTED' : r.status !== 'POSTED'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The screen that posts this entry. */
|
||||||
|
link(r: InvestmentDiaryRowDto): { path: string; query: Record<string, string | number> } {
|
||||||
|
if (r.instrument === 'FD') {
|
||||||
|
return { path: '/investment/fd', query: { tab: 'interest', fd: r.refId } };
|
||||||
|
}
|
||||||
|
const tab = r.kind === 'MATURITY' ? 'redemption' : r.kind === 'AMORTISATION' ? 'amortize' : 'interest';
|
||||||
|
return { path: '/investment/bonds', query: { tab } };
|
||||||
|
}
|
||||||
|
|
||||||
|
statusLabel(r: InvestmentDiaryRowDto): string {
|
||||||
|
return r.status === 'DUE' ? 'Due today' : r.status.charAt(0) + r.status.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
<!--
|
||||||
|
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">
|
||||||
|
<header class="pg-head">
|
||||||
|
<div>
|
||||||
|
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
|
||||||
|
<h1 class="pg-title">Total Interest Received & Receivable</h1>
|
||||||
|
<p class="pg-sub">
|
||||||
|
Interest received from Govt Securities, bonds and FDs in the period, and interest earned up to the To date that
|
||||||
|
has not come in yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="!report || generating" (click)="exportExcel()">
|
||||||
|
{{ generating ? 'Generating…' : 'Export to Excel' }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="inv-form-grid">
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>From</span><mifosx-date-ddmmyyyy [(ngModel)]="fromDate"></mifosx-date-ddmmyyyy
|
||||||
|
></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>To (receivable as on)</span><mifosx-date-ddmmyyyy [(ngModel)]="toDate"></mifosx-date-ddmmyyyy
|
||||||
|
></label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Investment</span>
|
||||||
|
<select [(ngModel)]="instrument">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="SECURITY">Govt Security / Bond</option>
|
||||||
|
<option value="FD">FD with banks</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="diary-actions">
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="loading" (click)="load()">
|
||||||
|
{{ loading ? 'Loading…' : 'Show' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (error) {
|
||||||
|
<p class="diary-error">{{ error }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (report; as r) {
|
||||||
|
<div class="diary-summary">
|
||||||
|
<div class="diary-tile">
|
||||||
|
<span class="diary-tile-l">Received</span
|
||||||
|
><span class="diary-tile-v">₹{{ totals.received | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diary-tile">
|
||||||
|
<span class="diary-tile-l">Receivable</span
|
||||||
|
><span class="diary-tile-v">₹{{ totals.receivable | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diary-tile">
|
||||||
|
<span class="diary-tile-l">Of which provisioned (100603)</span
|
||||||
|
><span class="diary-tile-v">₹{{ totals.provisioned | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diary-tile">
|
||||||
|
<span class="diary-tile-l">Total</span><span class="diary-tile-v">₹{{ totals.total | number: '1.2-2' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pg-card">
|
||||||
|
<div class="pos-section-label">
|
||||||
|
{{ r.fromDate }} to {{ r.toDate }} · received in the period, receivable as on {{ r.toDate }}
|
||||||
|
</div>
|
||||||
|
<table class="pg-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Investment</th>
|
||||||
|
<th>Deal No</th>
|
||||||
|
<th>Particulars</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="num">Principal / Face Value</th>
|
||||||
|
<th class="num">Rate</th>
|
||||||
|
<th class="num col-in">Received</th>
|
||||||
|
<th class="num">Receivable</th>
|
||||||
|
<th class="num">Of which provisioned</th>
|
||||||
|
<th class="num">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (x of rows; track x.instrument + x.refId) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ x.instrument === 'FD' ? 'FD' : 'Govt Security / Bond' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="pg-chip">{{ x.dealNo }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ x.particulars }}</td>
|
||||||
|
<td>{{ x.status }}</td>
|
||||||
|
<td class="num">₹{{ x.principal | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ x.rate !== null && x.rate !== undefined ? x.rate + '%' : '—' }}</td>
|
||||||
|
<td class="num col-in">₹{{ x.received | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ x.receivable | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ x.provisioned | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ x.total | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="pg-empty">No interest received or receivable in this period.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
@if (rows.length) {
|
||||||
|
<tfoot>
|
||||||
|
<tr class="report-total">
|
||||||
|
<td colspan="6">Total</td>
|
||||||
|
<td class="num col-in">₹{{ totals.received | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ totals.receivable | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ totals.provisioned | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ totals.total | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* 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, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||||
|
import { parseDDMMYYYY, todayDDMMYYYY } from '../investment-data';
|
||||||
|
import { exportWorkbook } from '../investment-export';
|
||||||
|
import {
|
||||||
|
InvestmentInterestReportDto,
|
||||||
|
InvestmentInterestReportRowDto,
|
||||||
|
InvestmentService,
|
||||||
|
apiErrorMessage
|
||||||
|
} from '../investment.service';
|
||||||
|
|
||||||
|
type InstrumentFilter = '' | 'SECURITY' | 'FD';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total Interest Received & Receivable: per Govt Security / bond deal and FD, the interest received in a period and the
|
||||||
|
* interest earned up to its last day that has not come in yet, with the part already provisioned in the books.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-investment-interest-report',
|
||||||
|
templateUrl: './investment-interest-report.component.html',
|
||||||
|
styleUrls: [
|
||||||
|
'../../cashier/cashier-shared.scss',
|
||||||
|
'../investment-shared.scss',
|
||||||
|
'../investment-diary/investment-diary.component.scss'
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
DateDdmmyyyyPickerComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class InvestmentInterestReportComponent implements OnInit {
|
||||||
|
private readonly investmentService = inject(InvestmentService);
|
||||||
|
|
||||||
|
/** Defaults to the current financial year, 1 April to today. */
|
||||||
|
fromDate = InvestmentInterestReportComponent.financialYearStart();
|
||||||
|
toDate = todayDDMMYYYY();
|
||||||
|
instrument: InstrumentFilter = '';
|
||||||
|
report: InvestmentInterestReportDto | null = null;
|
||||||
|
loading = false;
|
||||||
|
generating = false;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static financialYearStart(): string {
|
||||||
|
const d = parseDDMMYYYY(todayDDMMYYYY());
|
||||||
|
const year = d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
|
||||||
|
return `01-04-${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
if (!this.fromDate || !this.toDate) {
|
||||||
|
this.error = 'Enter the From and To dates.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.investmentService.getInterestReport(this.fromDate, this.toDate).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.report = r;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.report = null;
|
||||||
|
this.loading = false;
|
||||||
|
this.error = apiErrorMessage(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get rows(): InvestmentInterestReportRowDto[] {
|
||||||
|
return (this.report?.rows ?? []).filter((r) => !this.instrument || r.instrument === this.instrument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Totals of the rows shown, so they follow the Investment filter. */
|
||||||
|
get totals(): { received: number; receivable: number; provisioned: number; total: number } {
|
||||||
|
return this.rows.reduce(
|
||||||
|
(t, r) => ({
|
||||||
|
received: t.received + r.received,
|
||||||
|
receivable: t.receivable + r.receivable,
|
||||||
|
provisioned: t.provisioned + r.provisioned,
|
||||||
|
total: t.total + r.total
|
||||||
|
}),
|
||||||
|
{ received: 0, receivable: 0, provisioned: 0, total: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportExcel(): Promise<void> {
|
||||||
|
if (!this.report) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.generating = true;
|
||||||
|
try {
|
||||||
|
const t = this.totals;
|
||||||
|
await exportWorkbook(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sheetName: 'Interest Received',
|
||||||
|
title: 'Total Interest Received & Receivable',
|
||||||
|
subtitle: `${this.report.fromDate} to ${this.report.toDate}`,
|
||||||
|
headers: [
|
||||||
|
'Investment',
|
||||||
|
'Deal No',
|
||||||
|
'Particulars',
|
||||||
|
'Status',
|
||||||
|
'Principal / Face Value',
|
||||||
|
'Rate %',
|
||||||
|
'Received',
|
||||||
|
'Receivable',
|
||||||
|
'Of which provisioned',
|
||||||
|
'Total'
|
||||||
|
],
|
||||||
|
widths: [
|
||||||
|
14,
|
||||||
|
16,
|
||||||
|
40,
|
||||||
|
12,
|
||||||
|
18,
|
||||||
|
8,
|
||||||
|
16,
|
||||||
|
16,
|
||||||
|
18,
|
||||||
|
16
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
...this.rows.map((r) => [
|
||||||
|
r.instrument === 'FD' ? 'FD' : 'Govt Security / Bond',
|
||||||
|
r.dealNo,
|
||||||
|
r.particulars,
|
||||||
|
r.status,
|
||||||
|
r.principal,
|
||||||
|
r.rate ?? null,
|
||||||
|
r.received,
|
||||||
|
r.receivable,
|
||||||
|
r.provisioned,
|
||||||
|
r.total
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
'Total',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
t.received,
|
||||||
|
t.receivable,
|
||||||
|
t.provisioned,
|
||||||
|
t.total
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
`Interest-Received-Receivable-${this.report.toDate}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,38 +106,136 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PARTY MASTER -->
|
<!-- PARTY MASTER (layout follows the bank's existing CBS Bank / Party Master) -->
|
||||||
@if (activeTab === 'party') {
|
@if (activeTab === 'party') {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">Add Bank / Party</div>
|
<div class="pos-section-label">Bank / Party Master — Entry</div>
|
||||||
<div class="inv-form-grid">
|
<div class="inv-form-grid">
|
||||||
<label class="inv-form-field"
|
|
||||||
><span>Party Name *</span><input type="text" [(ngModel)]="newParty.partyName"
|
|
||||||
/></label>
|
|
||||||
<label class="inv-form-field">
|
<label class="inv-form-field">
|
||||||
<span>Party Type *</span>
|
<span>* Bond Type</span>
|
||||||
<select [(ngModel)]="newParty.partyType">
|
<select [(ngModel)]="partyForm.bondType">
|
||||||
<option [ngValue]="undefined">-- Select --</option>
|
<option [ngValue]="undefined">-- Select --</option>
|
||||||
<option value="BANK">BANK</option>
|
@for (t of instrumentTypes; track t) {
|
||||||
<option value="CORPORATE">CORPORATE</option>
|
<option [value]="t">{{ t }}</option>
|
||||||
<option value="GOVT">GOVT</option>
|
}
|
||||||
<option value="AMC">AMC</option>
|
|
||||||
<option value="EXCHANGE">EXCHANGE</option>
|
|
||||||
<option value="COOP_SOCIETY">COOP SOCIETY</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="inv-form-field"><span>PAN No</span><input type="text" [(ngModel)]="newParty.panNo" /></label>
|
<label class="inv-form-field"
|
||||||
|
><span>* Party Code</span><input type="text" [value]="partyForm.partyCode || nextPartyCode" readonly
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>* Party Name</span><input type="text" [(ngModel)]="partyForm.partyName"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Address Line 1 (Building / Flat No.)</span
|
||||||
|
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="partyForm.address1"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Address Line 2 (Street / Area)</span
|
||||||
|
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="partyForm.address2"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Address Line 3 (Locality / Landmark)</span
|
||||||
|
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="partyForm.address3"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="partyForm.city" /></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="partyForm.pincode"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="partyForm.phone" /></label>
|
||||||
|
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="partyForm.fax" /></label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Bank Type</span>
|
||||||
|
<select [(ngModel)]="partyForm.bankType">
|
||||||
|
<option [ngValue]="undefined">-- Select --</option>
|
||||||
|
@for (b of bankTypeOptions; track b) {
|
||||||
|
<option [value]="b">{{ b }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* GL A/c No.</span>
|
||||||
|
<select [(ngModel)]="partyForm.glAccountId">
|
||||||
|
<option [ngValue]="undefined">-- Select --</option>
|
||||||
|
@for (g of partyGlOptions; track g.id) {
|
||||||
|
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
@if (partyForm.bondType === 'FD') {
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Interest GL</span>
|
||||||
|
<select [(ngModel)]="partyForm.interestGlAccountId">
|
||||||
|
<option [ngValue]="undefined">-- Select --</option>
|
||||||
|
@for (g of interestGlOptions; track g.id) {
|
||||||
|
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Bank deducts TDS on FD interest?</span>
|
||||||
|
<select [ngModel]="partyForm.tdsApplicable ?? false" (ngModelChange)="partyForm.tdsApplicable = $event">
|
||||||
|
<option [ngValue]="false">No</option>
|
||||||
|
<option [ngValue]="true">Yes</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
@if (partyForm.tdsApplicable) {
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>* TDS rate (%)</span
|
||||||
|
><input type="number" min="0" max="99" step="0.01" [(ngModel)]="partyForm.tdsRate" placeholder="e.g. 10"
|
||||||
|
/></label>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<label class="inv-form-field"><span>RBI Code</span><input type="text" [(ngModel)]="partyForm.rbiCode" /></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Limit (₹)</span><input type="number" min="0" [(ngModel)]="partyForm.limit"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"><span>LEI No</span><input type="text" [(ngModel)]="partyForm.leiNo" /></label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select [(ngModel)]="partyForm.status">
|
||||||
|
<option [ngValue]="undefined">ACTIVE</option>
|
||||||
|
<option value="INACTIVE">INACTIVE</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="pos-section-label">Counterparty's Bank Details</div>
|
||||||
|
<div class="inv-form-grid">
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Name of Bank</span><input type="text" [(ngModel)]="partyForm.cpBankName"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Name of Branch</span><input type="text" [(ngModel)]="partyForm.cpBranchName"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Account No.</span><input type="text" [(ngModel)]="partyForm.cpAccountNo"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>IFSC</span><input type="text" maxlength="11" [(ngModel)]="partyForm.cpIfsc"
|
||||||
|
/></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="inv-form-actions">
|
<div class="inv-form-actions">
|
||||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addParty()">Save</button>
|
<label class="inv-form-field"
|
||||||
|
><span>Search Party Code</span><input type="text" placeholder="e.g. PTY-001" [(ngModel)]="partySearchCode"
|
||||||
|
/></label>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveParty()">Save</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchParty()">Search</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Cancel</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropParty()">Drop</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelParty()">Exit</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Party Code</th>
|
<th>Party Code</th>
|
||||||
|
<th>Bond Type</th>
|
||||||
<th>Party Name</th>
|
<th>Party Name</th>
|
||||||
<th>Type</th>
|
<th>City</th>
|
||||||
<th>PAN No</th>
|
<th>Bank Type</th>
|
||||||
|
<th>GL A/c</th>
|
||||||
|
<th class="num">Limit</th>
|
||||||
|
<th>Counterparty Bank / IFSC</th>
|
||||||
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -146,9 +244,14 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ p.partyCode }}</span>
|
<span class="pg-chip">{{ p.partyCode }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ p.bondType || '—' }}</td>
|
||||||
<td>{{ p.partyName }}</td>
|
<td>{{ p.partyName }}</td>
|
||||||
<td>{{ p.partyType }}</td>
|
<td>{{ p.city || '—' }}</td>
|
||||||
<td>{{ p.panNo }}</td>
|
<td>{{ p.bankType || '—' }}</td>
|
||||||
|
<td>{{ p.glCode ? p.glCode + ' — ' + glName(p.glCode) : '—' }}</td>
|
||||||
|
<td class="num">{{ (p.limit ?? null) !== null ? '₹' + (p.limit | number: '1.0-0') : '—' }}</td>
|
||||||
|
<td>{{ p.cpBankName ? p.cpBankName + (p.cpIfsc ? ' / ' + p.cpIfsc : '') : '—' }}</td>
|
||||||
|
<td>{{ p.status ?? 'ACTIVE' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -156,42 +259,176 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- SCRIPT MASTER -->
|
<!-- SCRIPT MASTER (layout follows the bank's existing CBS Script Master) -->
|
||||||
@if (activeTab === 'script') {
|
@if (activeTab === 'script') {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">Add Script</div>
|
<div class="pos-section-label">Script Master — Entry</div>
|
||||||
<div class="inv-form-grid">
|
<div class="inv-form-grid">
|
||||||
<label class="inv-form-field"
|
|
||||||
><span>Script Code</span><input type="text" [(ngModel)]="newScript.scriptCode"
|
|
||||||
/></label>
|
|
||||||
<label class="inv-form-field"
|
|
||||||
><span>Script Name *</span><input type="text" [(ngModel)]="newScript.scriptName"
|
|
||||||
/></label>
|
|
||||||
<label class="inv-form-field">
|
<label class="inv-form-field">
|
||||||
<span>Instrument Type *</span>
|
<span>* Bond Type</span>
|
||||||
<select [(ngModel)]="newScript.instrumentType">
|
<select [(ngModel)]="scriptForm.instrumentType" [disabled]="!!scriptForm.scriptCode">
|
||||||
<option [ngValue]="undefined">-- Select --</option>
|
<option [ngValue]="undefined">-- Select --</option>
|
||||||
@for (t of instrumentTypes; track t) {
|
@for (t of instrumentTypes; track t) {
|
||||||
<option [value]="t">{{ t }}</option>
|
<option [value]="t">{{ t }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="inv-form-field"><span>ISIN</span><input type="text" [(ngModel)]="newScript.isin" /></label>
|
<label class="inv-form-field">
|
||||||
|
<span>SLR</span>
|
||||||
|
<select [(ngModel)]="scriptForm.slrType">
|
||||||
|
<option [ngValue]="undefined">-- Select --</option>
|
||||||
|
@for (o of slrTypeOptions; track o.value) {
|
||||||
|
<option [value]="o.value">{{ o.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="inv-form-field"
|
<label class="inv-form-field"
|
||||||
><span>Face Value / Unit</span><input type="number" [(ngModel)]="newScript.faceValuePerUnit"
|
><span>* Script Code</span><input type="text" [value]="scriptForm.scriptCode || nextScriptCode" readonly
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>* Script Desc</span
|
||||||
|
><input type="text" placeholder="e.g. 7.50%GOI2034" [(ngModel)]="scriptForm.scriptName"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* GL A/c No.</span>
|
||||||
|
<select [(ngModel)]="scriptForm.glAccountId">
|
||||||
|
<option [ngValue]="undefined">-- Select --</option>
|
||||||
|
@for (g of scriptGlOptions; track g.id) {
|
||||||
|
<option [ngValue]="g.id">{{ g.glCode }} — {{ g.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}ISIN</span><input type="text" [(ngModel)]="scriptForm.isin"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Int Rate %</span
|
||||||
|
><input type="number" step="0.01" min="0" [(ngModel)]="scriptForm.couponRate"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Issue Date</span
|
||||||
|
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.issueDate"></mifosx-date-ddmmyyyy
|
||||||
|
></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>{{ scriptIsDebtSecurity ? '* ' : '' }}Redum / Mat Date</span
|
||||||
|
><mifosx-date-ddmmyyyy [(ngModel)]="scriptForm.maturityDate"></mifosx-date-ddmmyyyy
|
||||||
|
></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Period (Year)</span><input type="text" [value]="scriptPeriod.years" readonly
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Period (Month)</span><input type="text" [value]="scriptPeriod.months" readonly
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Period (Days)</span><input type="text" [value]="scriptPeriod.days" readonly
|
||||||
/></label>
|
/></label>
|
||||||
</div>
|
</div>
|
||||||
|
@if (scriptIsRated) {
|
||||||
|
<div class="pos-section-label">Credit Rating (RBI: bonds "A" or higher, CP / CD "A2" or higher)</div>
|
||||||
|
<div class="inv-form-grid">
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>* Rating</span>
|
||||||
|
<select [(ngModel)]="scriptForm.rating">
|
||||||
|
<option [ngValue]="undefined">-- Select --</option>
|
||||||
|
@for (g of ratingOptions; track g.group) {
|
||||||
|
<optgroup [label]="g.group">
|
||||||
|
@for (r of g.values; track r) {
|
||||||
|
<option [value]="r">{{ r }}</option>
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Rating Agency</span>
|
||||||
|
<select [(ngModel)]="scriptForm.ratingAgency">
|
||||||
|
<option [ngValue]="undefined">-- Select --</option>
|
||||||
|
@for (a of ratingAgencyOptions; track a.value) {
|
||||||
|
<option [value]="a.value">{{ a.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Listing</span>
|
||||||
|
<select
|
||||||
|
[ngModel]="scriptForm.listed === false ? 'UNLISTED' : 'LISTED'"
|
||||||
|
(ngModelChange)="scriptForm.listed = $event !== 'UNLISTED'"
|
||||||
|
>
|
||||||
|
<option value="LISTED">Listed / proposed to be listed</option>
|
||||||
|
<option value="UNLISTED">Unlisted</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@if (scriptForm.rating) {
|
||||||
|
<p class="inv-hint" [class.inv-hint--bad]="!scriptRatingEligible">
|
||||||
|
{{
|
||||||
|
scriptRatingEligible
|
||||||
|
? 'Rating meets the RBI minimum; this security can be bought.'
|
||||||
|
: 'Below the RBI minimum rating; a purchase of this security will be blocked.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<div class="pos-section-label">Company Details (Bond / Mutual Fund)</div>
|
||||||
|
<div class="inv-form-grid">
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Company Name</span><input type="text" [(ngModel)]="scriptForm.companyName"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Address Line 1 (Building / Flat No.)</span
|
||||||
|
><input type="text" placeholder="e.g. Shop No. 12, Shanti Complex" [(ngModel)]="scriptForm.address1"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Address Line 2 (Street / Area)</span
|
||||||
|
><input type="text" placeholder="e.g. MG Road, Near Bus Stand" [(ngModel)]="scriptForm.address2"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Address Line 3 (Locality / Landmark)</span
|
||||||
|
><input type="text" placeholder="e.g. Arera Colony" [(ngModel)]="scriptForm.address3"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"><span>City</span><input type="text" [(ngModel)]="scriptForm.city" /></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Pincode</span><input type="text" maxlength="6" [(ngModel)]="scriptForm.pincode"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field"><span>Phone</span><input type="text" [(ngModel)]="scriptForm.phone" /></label>
|
||||||
|
<label class="inv-form-field"><span>Fax</span><input type="text" [(ngModel)]="scriptForm.fax" /></label>
|
||||||
|
<label class="inv-form-field"
|
||||||
|
><span>Corp. Code</span><input type="text" [(ngModel)]="scriptForm.corpCode"
|
||||||
|
/></label>
|
||||||
|
<label class="inv-form-field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select
|
||||||
|
[ngModel]="scriptForm.active === false ? 'INACTIVE' : 'ACTIVE'"
|
||||||
|
(ngModelChange)="scriptForm.active = $event !== 'INACTIVE'"
|
||||||
|
>
|
||||||
|
<option value="ACTIVE">ACTIVE</option>
|
||||||
|
<option value="INACTIVE">INACTIVE</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="inv-form-actions">
|
<div class="inv-form-actions">
|
||||||
<button type="button" class="pg-btn pg-btn--primary" (click)="addScript()">Save</button>
|
<label class="inv-form-field"
|
||||||
|
><span>Search Script Code</span><input type="text" placeholder="e.g. G00001" [(ngModel)]="scriptSearchCode"
|
||||||
|
/></label>
|
||||||
|
<button type="button" class="pg-btn pg-btn--primary" [disabled]="saving" (click)="saveScript()">Save</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="searchScript()">Search</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Cancel</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="cancelScript()">Exit</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Script Code</th>
|
<th>Script Code</th>
|
||||||
<th>Script Name</th>
|
<th>Bond Type</th>
|
||||||
<th>Instrument</th>
|
<th>SLR</th>
|
||||||
|
<th>Script Desc</th>
|
||||||
|
<th>GL A/c</th>
|
||||||
<th>ISIN</th>
|
<th>ISIN</th>
|
||||||
<th class="num">Face Value/Unit</th>
|
<th class="num">Int Rate %</th>
|
||||||
|
<th>Issue Date</th>
|
||||||
|
<th>Maturity</th>
|
||||||
|
<th>Rating</th>
|
||||||
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -200,10 +437,16 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ s.scriptCode }}</span>
|
<span class="pg-chip">{{ s.scriptCode }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ s.scriptName }}</td>
|
|
||||||
<td>{{ s.instrumentType }}</td>
|
<td>{{ s.instrumentType }}</td>
|
||||||
|
<td>{{ s.slrType || '—' }}</td>
|
||||||
|
<td>{{ s.scriptName }}</td>
|
||||||
|
<td>{{ s.glCode || '—' }}</td>
|
||||||
<td>{{ s.isin || '—' }}</td>
|
<td>{{ s.isin || '—' }}</td>
|
||||||
<td class="num">{{ s.faceValuePerUnit }}</td>
|
<td class="num">{{ s.couponRate ?? '—' }}</td>
|
||||||
|
<td>{{ s.issueDate || '—' }}</td>
|
||||||
|
<td>{{ s.maturityDate || '—' }}</td>
|
||||||
|
<td>{{ s.rating ? s.rating + (s.listed === false ? ' · Unlisted' : '') : '—' }}</td>
|
||||||
|
<td>{{ s.active === false ? 'INACTIVE' : 'ACTIVE' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -386,10 +629,11 @@
|
|||||||
<th class="num">High</th>
|
<th class="num">High</th>
|
||||||
<th class="num">Low</th>
|
<th class="num">Low</th>
|
||||||
<th class="num">Close</th>
|
<th class="num">Close</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (r of dailyScriptRates; track r.rateDate + r.scriptCode) {
|
@for (r of dailyScriptRates; track r.id ?? r.rateDate + r.scriptCode) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ r.rateDate }}</td>
|
<td>{{ r.rateDate }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -400,6 +644,13 @@
|
|||||||
<td class="num">{{ r.highRate | number: '1.2-2' }}</td>
|
<td class="num">{{ r.highRate | number: '1.2-2' }}</td>
|
||||||
<td class="num">{{ r.lowRate | number: '1.2-2' }}</td>
|
<td class="num">{{ r.lowRate | number: '1.2-2' }}</td>
|
||||||
<td class="num">{{ r.closeRate | number: '1.2-2' }}</td>
|
<td class="num">{{ r.closeRate | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" (click)="dropDailyRate(r)">Drop</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="pg-empty">No rates entered yet.</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -6,20 +6,17 @@
|
|||||||
* 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 } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
import { DateDdmmyyyyPickerComponent } from '../date-ddmmyyyy-picker/date-ddmmyyyy-picker.component';
|
||||||
import {
|
import {
|
||||||
SCRIPT_MASTER,
|
|
||||||
BROKER_MASTER,
|
BROKER_MASTER,
|
||||||
PARTY_MASTER,
|
|
||||||
SUBTYPE_MASTER,
|
SUBTYPE_MASTER,
|
||||||
CONTROL_PARAMETERS,
|
CONTROL_PARAMETERS,
|
||||||
MUTUAL_FUND_PRODUCTS,
|
MUTUAL_FUND_PRODUCTS,
|
||||||
SGL_BANK_MASTER,
|
SGL_BANK_MASTER,
|
||||||
SCRIPT_MARKET_DETAIL,
|
SCRIPT_MARKET_DETAIL,
|
||||||
DAILY_SCRIPT_RATES,
|
|
||||||
LIEN_REASON_MASTER,
|
LIEN_REASON_MASTER,
|
||||||
ScriptMaster,
|
ScriptMaster,
|
||||||
BrokerMaster,
|
BrokerMaster,
|
||||||
@@ -31,9 +28,17 @@ import {
|
|||||||
ScriptMarketDetail,
|
ScriptMarketDetail,
|
||||||
DailyScriptRate,
|
DailyScriptRate,
|
||||||
LienReason,
|
LienReason,
|
||||||
InstrumentType
|
InstrumentType,
|
||||||
|
BANK_TYPE_OPTIONS,
|
||||||
|
SLR_TYPE_OPTIONS,
|
||||||
|
INVESTMENT_GL_LOOKUP,
|
||||||
|
ELIGIBLE_RATINGS,
|
||||||
|
RATING_AGENCY_OPTIONS,
|
||||||
|
RATING_OPTIONS,
|
||||||
|
periodBetween
|
||||||
} from '../investment-data';
|
} from '../investment-data';
|
||||||
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||||
|
import { InvestmentGlAccount, InvestmentService, apiErrorMessage, toDailyScriptRate } from '../investment.service';
|
||||||
|
|
||||||
type MasterTab =
|
type MasterTab =
|
||||||
| 'party'
|
| 'party'
|
||||||
@@ -51,6 +56,10 @@ type MasterTab =
|
|||||||
* Investment master-data screens (legacy "04-13" Entry submenu masters) — 9 simple dummy
|
* Investment master-data screens (legacy "04-13" Entry submenu masters) — 9 simple dummy
|
||||||
* lists, each with an inline Add-row form so the demo stays interactive without a backend.
|
* lists, each with an inline Add-row form so the demo stays interactive without a backend.
|
||||||
*/
|
*/
|
||||||
|
/** Detail GLs under the 100700 INVESTMENTS head; 100707 holds premium only, never a party's or script's GL. */
|
||||||
|
const INVESTMENT_GL_HEAD_PREFIX = '1007';
|
||||||
|
const PREMIUM_ON_INVESTMENTS_GL_CODE = '100707';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-investment-masters',
|
selector: 'mifosx-investment-masters',
|
||||||
templateUrl: './investment-masters.component.html',
|
templateUrl: './investment-masters.component.html',
|
||||||
@@ -65,18 +74,19 @@ type MasterTab =
|
|||||||
DateDdmmyyyyPickerComponent
|
DateDdmmyyyyPickerComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class InvestmentMastersComponent {
|
export class InvestmentMastersComponent implements OnInit {
|
||||||
activeTab: MasterTab = 'party';
|
activeTab: MasterTab = 'party';
|
||||||
|
|
||||||
scripts: ScriptMaster[] = [...SCRIPT_MASTER];
|
scripts: ScriptMaster[] = [];
|
||||||
brokers: BrokerMaster[] = [...BROKER_MASTER];
|
brokers: BrokerMaster[] = [...BROKER_MASTER];
|
||||||
parties: PartyMaster[] = [...PARTY_MASTER];
|
parties: PartyMaster[] = [];
|
||||||
subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER];
|
subTypes: SubTypeMaster[] = [...SUBTYPE_MASTER];
|
||||||
controlParams: ControlParameter[] = [...CONTROL_PARAMETERS];
|
controlParams: ControlParameter[] = [...CONTROL_PARAMETERS];
|
||||||
mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS];
|
mfProducts: MutualFundProductMaster[] = [...MUTUAL_FUND_PRODUCTS];
|
||||||
sglBanks: SglBankMaster[] = [...SGL_BANK_MASTER];
|
sglBanks: SglBankMaster[] = [...SGL_BANK_MASTER];
|
||||||
marketDetails: ScriptMarketDetail[] = [...SCRIPT_MARKET_DETAIL];
|
marketDetails: ScriptMarketDetail[] = [...SCRIPT_MARKET_DETAIL];
|
||||||
dailyScriptRates: DailyScriptRate[] = [...DAILY_SCRIPT_RATES];
|
/** Loaded from the backend (Daily Script Master) in ngOnInit. */
|
||||||
|
dailyScriptRates: DailyScriptRate[] = [];
|
||||||
lienReasons: LienReason[] = [...LIEN_REASON_MASTER];
|
lienReasons: LienReason[] = [...LIEN_REASON_MASTER];
|
||||||
|
|
||||||
readonly instrumentTypes: InstrumentType[] = [
|
readonly instrumentTypes: InstrumentType[] = [
|
||||||
@@ -106,8 +116,23 @@ export class InvestmentMastersComponent {
|
|||||||
toastVisible = false;
|
toastVisible = false;
|
||||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
newParty: Partial<PartyMaster> = {};
|
// Bank / Party Master and Script Master follow the bank's existing CBS screens (one entry form
|
||||||
newScript: Partial<ScriptMaster> = {};
|
// with Save / Search / Cancel / Drop / Exit) and are stored in the backend through
|
||||||
|
// InvestmentService; the other master tabs are still sample data.
|
||||||
|
private readonly investmentService = inject(InvestmentService);
|
||||||
|
partyForm: Partial<PartyMaster> = {};
|
||||||
|
partySearchCode = '';
|
||||||
|
scriptForm: Partial<ScriptMaster> = {};
|
||||||
|
scriptSearchCode = '';
|
||||||
|
saving = false;
|
||||||
|
readonly bankTypeOptions = BANK_TYPE_OPTIONS;
|
||||||
|
readonly slrTypeOptions = SLR_TYPE_OPTIONS;
|
||||||
|
readonly ratingOptions = RATING_OPTIONS;
|
||||||
|
readonly ratingAgencyOptions = RATING_AGENCY_OPTIONS;
|
||||||
|
/** Investment GLs (under the 100700 INVESTMENTS head) a party or script can be mapped to — not loans, banks or test GLs. */
|
||||||
|
investmentGlOptions: InvestmentGlAccount[] = [];
|
||||||
|
/** Income GLs under 400400 INCOME FROM INVESTMENTS, for an FD bank's interest. */
|
||||||
|
interestGlOptions: InvestmentGlAccount[] = [];
|
||||||
newSubType: Partial<SubTypeMaster> = {};
|
newSubType: Partial<SubTypeMaster> = {};
|
||||||
newBroker: Partial<BrokerMaster> = {};
|
newBroker: Partial<BrokerMaster> = {};
|
||||||
newControlParam: Partial<ControlParameter> = {};
|
newControlParam: Partial<ControlParameter> = {};
|
||||||
@@ -116,43 +141,273 @@ export class InvestmentMastersComponent {
|
|||||||
newSglBank: Partial<SglBankMaster> = {};
|
newSglBank: Partial<SglBankMaster> = {};
|
||||||
newDailyRate: Partial<DailyScriptRate> = {};
|
newDailyRate: Partial<DailyScriptRate> = {};
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadDailyRates();
|
||||||
|
this.investmentService.getAssetGlAccounts().subscribe({
|
||||||
|
next: (gls) =>
|
||||||
|
(this.investmentGlOptions = gls.filter(
|
||||||
|
(g) => g.glCode.startsWith(INVESTMENT_GL_HEAD_PREFIX) && g.glCode !== PREMIUM_ON_INVESTMENTS_GL_CODE
|
||||||
|
)),
|
||||||
|
error: (err) => this.toast(`Could not load GL accounts: ${apiErrorMessage(err)}`)
|
||||||
|
});
|
||||||
|
this.investmentService.getIncomeGlAccounts().subscribe({
|
||||||
|
next: (gls) => (this.interestGlOptions = gls.filter((g) => g.glCode.startsWith('4004'))),
|
||||||
|
error: (err) => this.toast(`Could not load income GLs: ${apiErrorMessage(err)}`)
|
||||||
|
});
|
||||||
|
this.reloadMasters();
|
||||||
|
}
|
||||||
|
|
||||||
setTab(tab: MasterTab): void {
|
setTab(tab: MasterTab): void {
|
||||||
this.activeTab = tab;
|
this.activeTab = tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
addParty(): void {
|
/** Re-reads both masters from the backend (also refreshes the lists the Purchase screen uses). */
|
||||||
if (!this.newParty.partyName || !this.newParty.partyType) {
|
private reloadMasters(): void {
|
||||||
this.toast('Enter Party Name and Type.');
|
this.investmentService.syncSharedMasters().subscribe({
|
||||||
return;
|
next: ({ parties, scripts }) => {
|
||||||
}
|
this.parties = parties;
|
||||||
this.parties = [
|
this.scripts = scripts;
|
||||||
{
|
},
|
||||||
partyCode: `PTY-${String(this.parties.length + 1).padStart(3, '0')}`,
|
error: (err) => this.toast(`Could not load masters: ${apiErrorMessage(err)}`)
|
||||||
panNo: '',
|
});
|
||||||
...this.newParty
|
|
||||||
} as PartyMaster,
|
|
||||||
...this.parties
|
|
||||||
];
|
|
||||||
this.newParty = {};
|
|
||||||
this.toast('Party/Bank master added.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addScript(): void {
|
glName(glCode?: string): string {
|
||||||
if (!this.newScript.scriptName || !this.newScript.instrumentType) {
|
return (
|
||||||
this.toast('Enter Script Name and Instrument Type.');
|
this.investmentGlOptions.find((g) => g.glCode === glCode)?.name ??
|
||||||
|
INVESTMENT_GL_LOOKUP.find((g) => g.glCode === glCode)?.glName ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FD GLs are the bank-wise children of 100704 (codes 1007040x). */
|
||||||
|
private static isFdGl(glCode: string): boolean {
|
||||||
|
return glCode.startsWith('1007040');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An FD bank is mapped to its own FD GL; any other party to a non-FD investment GL. */
|
||||||
|
get partyGlOptions(): InvestmentGlAccount[] {
|
||||||
|
const fd = this.partyForm.bondType === 'FD';
|
||||||
|
return this.investmentGlOptions.filter((g) => InvestmentMastersComponent.isFdGl(g.glCode) === fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scripts are securities, never an FD with a bank. */
|
||||||
|
get scriptGlOptions(): InvestmentGlAccount[] {
|
||||||
|
return this.investmentGlOptions.filter((g) => !InvestmentMastersComponent.isFdGl(g.glCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Bank / Party Master --------------------
|
||||||
|
/** Preview of the code the backend will assign; the saved code always comes from the backend. */
|
||||||
|
get nextPartyCode(): string {
|
||||||
|
const nums = this.parties.map((p) => parseInt(p.partyCode.replace(/\D/g, ''), 10)).filter((n) => !isNaN(n));
|
||||||
|
return `PTY-${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(3, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveParty(): void {
|
||||||
|
const f = this.partyForm;
|
||||||
|
if (!f.bondType || !f.partyName || !f.glAccountId) {
|
||||||
|
this.toast('Enter Bond Type, Party Name and GL A/c No.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.scripts = [
|
if (f.bondType === 'FD' && !f.interestGlAccountId) {
|
||||||
{
|
this.toast('An FD bank needs its Interest GL as well.');
|
||||||
scriptCode: this.newScript.scriptCode || `SCR-${this.scripts.length + 1}`,
|
return;
|
||||||
isin: '',
|
}
|
||||||
faceValuePerUnit: 100,
|
const tds = f.bondType === 'FD' && !!f.tdsApplicable;
|
||||||
...this.newScript
|
if (tds && !(Number(f.tdsRate) > 0 && Number(f.tdsRate) < 100)) {
|
||||||
} as ScriptMaster,
|
this.toast('Enter the TDS rate (%) this bank deducts on FD interest.');
|
||||||
...this.scripts
|
return;
|
||||||
];
|
}
|
||||||
this.newScript = {};
|
const body = {
|
||||||
this.toast('Script master added.');
|
bondType: f.bondType,
|
||||||
|
partyName: f.partyName,
|
||||||
|
address1: f.address1 ?? '',
|
||||||
|
address2: f.address2 ?? '',
|
||||||
|
address3: f.address3 ?? '',
|
||||||
|
city: f.city ?? '',
|
||||||
|
pincode: f.pincode ?? '',
|
||||||
|
phone: f.phone ?? '',
|
||||||
|
fax: f.fax ?? '',
|
||||||
|
bankType: f.bankType ?? '',
|
||||||
|
glAccountId: Number(f.glAccountId),
|
||||||
|
interestGlAccountId: f.bondType === 'FD' && f.interestGlAccountId ? Number(f.interestGlAccountId) : null,
|
||||||
|
tdsApplicable: tds,
|
||||||
|
tdsRate: tds ? Number(f.tdsRate) : null,
|
||||||
|
rbiCode: f.rbiCode ?? '',
|
||||||
|
limit: f.limit ?? null,
|
||||||
|
leiNo: f.leiNo ?? '',
|
||||||
|
cpBankName: f.cpBankName ?? '',
|
||||||
|
cpBranchName: f.cpBranchName ?? '',
|
||||||
|
cpAccountNo: f.cpAccountNo ?? '',
|
||||||
|
cpIfsc: f.cpIfsc ?? '',
|
||||||
|
active: f.status !== 'INACTIVE',
|
||||||
|
locale: 'en'
|
||||||
|
};
|
||||||
|
const request = f.id ? this.investmentService.updateParty(f.id, body) : this.investmentService.createParty(body);
|
||||||
|
this.saving = true;
|
||||||
|
request.subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.saving = false;
|
||||||
|
this.toast(`Party ${res.resourceIdentifier ?? f.partyCode} ${f.id ? 'updated' : 'saved'}.`);
|
||||||
|
this.partyForm = {};
|
||||||
|
this.reloadMasters();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.saving = false;
|
||||||
|
this.toast(apiErrorMessage(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
searchParty(): void {
|
||||||
|
const p = this.parties.find((x) => x.partyCode.toUpperCase() === this.partySearchCode.trim().toUpperCase());
|
||||||
|
if (!p) {
|
||||||
|
this.toast(`Party Code ${this.partySearchCode} not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.partyForm = { ...p };
|
||||||
|
this.toast(`Party ${p.partyCode} loaded.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelParty(): void {
|
||||||
|
this.partyForm = {};
|
||||||
|
this.partySearchCode = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
dropParty(): void {
|
||||||
|
const f = this.partyForm;
|
||||||
|
if (!f.id) {
|
||||||
|
this.toast('Search a saved party first, then Drop.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.investmentService.deleteParty(f.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast(`Party ${f.partyCode} dropped.`);
|
||||||
|
this.partyForm = {};
|
||||||
|
this.reloadMasters();
|
||||||
|
},
|
||||||
|
error: (err) => this.toast(apiErrorMessage(err))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------- Script Master --------------------
|
||||||
|
/** Coupon / maturity terms only apply to debt securities. */
|
||||||
|
get scriptIsDebtSecurity(): boolean {
|
||||||
|
return [
|
||||||
|
'GOVT_SECURITY',
|
||||||
|
'NON_SLR',
|
||||||
|
'BOND'
|
||||||
|
].includes(this.scriptForm.instrumentType ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corporate bonds, CPs and CDs carry a credit rating and a listed / unlisted flag (RBI non-SLR rules). */
|
||||||
|
get scriptIsRated(): boolean {
|
||||||
|
return [
|
||||||
|
'NON_SLR',
|
||||||
|
'BOND'
|
||||||
|
].includes(this.scriptForm.instrumentType ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the rating entered meets the RBI minimum: "A" for bonds, "A2" for CP / CD. */
|
||||||
|
get scriptRatingEligible(): boolean {
|
||||||
|
return !!this.scriptForm.rating && ELIGIBLE_RATINGS.includes(this.scriptForm.rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview of the code the backend will assign — a type letter + running number, e.g. G00001. */
|
||||||
|
get nextScriptCode(): string {
|
||||||
|
const prefixByType: Record<InstrumentType, string> = {
|
||||||
|
GOVT_SECURITY: 'G',
|
||||||
|
NON_SLR: 'N',
|
||||||
|
BOND: 'B',
|
||||||
|
MUTUAL_FUND: 'M',
|
||||||
|
FD: 'F',
|
||||||
|
SHARE: 'S'
|
||||||
|
};
|
||||||
|
const type = this.scriptForm.instrumentType;
|
||||||
|
if (!type) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const prefix = prefixByType[type];
|
||||||
|
const pattern = new RegExp(`^${prefix}(\\d{5})$`);
|
||||||
|
const nums = this.scripts
|
||||||
|
.map((s) => pattern.exec(s.scriptCode)?.[1])
|
||||||
|
.filter((n): n is string => !!n)
|
||||||
|
.map((n) => parseInt(n, 10));
|
||||||
|
return `${prefix}${String((nums.length ? Math.max(...nums) : 0) + 1).padStart(5, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get scriptPeriod(): { years: number; months: number; days: number } {
|
||||||
|
return periodBetween(this.scriptForm.issueDate, this.scriptForm.maturityDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveScript(): void {
|
||||||
|
const f = this.scriptForm;
|
||||||
|
if (!f.instrumentType || !f.scriptName || !f.glAccountId) {
|
||||||
|
this.toast('Enter Bond Type, Script Desc and GL A/c No.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.scriptIsDebtSecurity && (!f.isin || f.couponRate == null || !f.issueDate || !f.maturityDate)) {
|
||||||
|
this.toast('Enter ISIN, Int Rate, Issue Date and Maturity Date for this security.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.scriptIsRated && !f.rating) {
|
||||||
|
this.toast('Enter the credit rating of this bond / CP / CD.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
bondType: f.instrumentType,
|
||||||
|
slrType: f.slrType ?? '',
|
||||||
|
scriptDesc: f.scriptName,
|
||||||
|
glAccountId: Number(f.glAccountId),
|
||||||
|
isin: f.isin ?? '',
|
||||||
|
intRate: f.couponRate ?? null,
|
||||||
|
issueDate: f.issueDate ?? '',
|
||||||
|
maturityDate: f.maturityDate ?? '',
|
||||||
|
companyName: f.companyName ?? '',
|
||||||
|
address1: f.address1 ?? '',
|
||||||
|
address2: f.address2 ?? '',
|
||||||
|
address3: f.address3 ?? '',
|
||||||
|
city: f.city ?? '',
|
||||||
|
pincode: f.pincode ?? '',
|
||||||
|
phone: f.phone ?? '',
|
||||||
|
fax: f.fax ?? '',
|
||||||
|
corpCode: f.corpCode ?? '',
|
||||||
|
rating: this.scriptIsRated ? (f.rating ?? '') : '',
|
||||||
|
ratingAgency: this.scriptIsRated ? (f.ratingAgency ?? '') : '',
|
||||||
|
listed: !this.scriptIsRated || f.listed !== false,
|
||||||
|
active: f.active !== false,
|
||||||
|
dateFormat: 'dd-MM-yyyy',
|
||||||
|
locale: 'en'
|
||||||
|
};
|
||||||
|
const request = f.id ? this.investmentService.updateScript(f.id, body) : this.investmentService.createScript(body);
|
||||||
|
this.saving = true;
|
||||||
|
request.subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this.saving = false;
|
||||||
|
this.toast(`Script ${res.resourceIdentifier ?? f.scriptCode} ${f.id ? 'updated' : 'saved'}.`);
|
||||||
|
this.scriptForm = {};
|
||||||
|
this.reloadMasters();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.saving = false;
|
||||||
|
this.toast(apiErrorMessage(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
searchScript(): void {
|
||||||
|
const s = this.scripts.find((x) => x.scriptCode.toUpperCase() === this.scriptSearchCode.trim().toUpperCase());
|
||||||
|
if (!s) {
|
||||||
|
this.toast(`Script Code ${this.scriptSearchCode} not found.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.scriptForm = { ...s };
|
||||||
|
this.toast(`Script ${s.scriptCode} loaded.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelScript(): void {
|
||||||
|
this.scriptForm = {};
|
||||||
|
this.scriptSearchCode = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
addSubType(): void {
|
addSubType(): void {
|
||||||
@@ -248,24 +503,56 @@ export class InvestmentMastersComponent {
|
|||||||
this.toast('SGL Bank master added.');
|
this.toast('SGL Bank master added.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadDailyRates(): void {
|
||||||
|
this.investmentService.getDailyRates().subscribe({
|
||||||
|
next: (rows) => (this.dailyScriptRates = rows.map(toDailyScriptRate)),
|
||||||
|
error: (err) => this.toast(`Could not load daily script rates: ${apiErrorMessage(err)}`)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
addDailyRate(): void {
|
addDailyRate(): void {
|
||||||
if (!this.newDailyRate.scriptCode || !this.newDailyRate.rateDate || this.newDailyRate.closeRate == null) {
|
const r = this.newDailyRate;
|
||||||
|
if (!r.scriptCode || !r.rateDate || r.closeRate == null) {
|
||||||
this.toast('Enter Rate Date, Script Code and Close Rate.');
|
this.toast('Enter Rate Date, Script Code and Close Rate.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const script = this.scripts.find((s) => s.scriptCode === this.newDailyRate.scriptCode);
|
const script = this.scripts.find((s) => s.scriptCode === r.scriptCode);
|
||||||
this.dailyScriptRates = [
|
if (!script?.id) {
|
||||||
{
|
this.toast(`Script ${r.scriptCode} is not saved in Script Master.`);
|
||||||
scriptName: script?.scriptName || '',
|
return;
|
||||||
openRate: this.newDailyRate.closeRate,
|
}
|
||||||
highRate: this.newDailyRate.closeRate,
|
const body = {
|
||||||
lowRate: this.newDailyRate.closeRate,
|
scriptId: script.id,
|
||||||
...this.newDailyRate
|
rateDate: r.rateDate,
|
||||||
} as DailyScriptRate,
|
closeRate: r.closeRate,
|
||||||
...this.dailyScriptRates
|
...(r.openRate != null ? { openRate: r.openRate } : {}),
|
||||||
];
|
...(r.highRate != null ? { highRate: r.highRate } : {}),
|
||||||
|
...(r.lowRate != null ? { lowRate: r.lowRate } : {}),
|
||||||
|
dateFormat: 'dd-MM-yyyy',
|
||||||
|
locale: 'en'
|
||||||
|
};
|
||||||
|
this.investmentService.createDailyRate(body).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast(`Rate for ${r.scriptCode} on ${r.rateDate} saved.`);
|
||||||
this.newDailyRate = {};
|
this.newDailyRate = {};
|
||||||
this.toast('Daily script rate added.');
|
this.loadDailyRates();
|
||||||
|
},
|
||||||
|
error: (err) => this.toast(apiErrorMessage(err))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A wrong rate is corrected by dropping it and entering it again. */
|
||||||
|
dropDailyRate(r: DailyScriptRate): void {
|
||||||
|
if (!r.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.investmentService.deleteDailyRate(r.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.toast(`Rate for ${r.scriptCode} on ${r.rateDate} dropped.`);
|
||||||
|
this.loadDailyRates();
|
||||||
|
},
|
||||||
|
error: (err) => this.toast(apiErrorMessage(err))
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Export to Excel --------------------
|
// -------------------- Export to Excel --------------------
|
||||||
@@ -279,46 +566,121 @@ export class InvestmentMastersComponent {
|
|||||||
return one(
|
return one(
|
||||||
[
|
[
|
||||||
'Party Code',
|
'Party Code',
|
||||||
|
'Bond Type',
|
||||||
'Party Name',
|
'Party Name',
|
||||||
'Type',
|
'Address',
|
||||||
'PAN No'
|
'City',
|
||||||
|
'Pincode',
|
||||||
|
'Phone',
|
||||||
|
'Fax',
|
||||||
|
'Bank Type',
|
||||||
|
'GL A/c No',
|
||||||
|
'RBI Code',
|
||||||
|
'Limit',
|
||||||
|
'LEI No',
|
||||||
|
'Counterparty Bank',
|
||||||
|
'Branch',
|
||||||
|
'Account No',
|
||||||
|
'IFSC',
|
||||||
|
'Status'
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
12,
|
12,
|
||||||
|
14,
|
||||||
30,
|
30,
|
||||||
16,
|
36,
|
||||||
14
|
14,
|
||||||
|
10,
|
||||||
|
14,
|
||||||
|
14,
|
||||||
|
24,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
14,
|
||||||
|
22,
|
||||||
|
24,
|
||||||
|
20,
|
||||||
|
18,
|
||||||
|
14,
|
||||||
|
10
|
||||||
],
|
],
|
||||||
this.parties.map((p) => [
|
this.parties.map((p) => [
|
||||||
p.partyCode,
|
p.partyCode,
|
||||||
|
p.bondType ?? '',
|
||||||
p.partyName,
|
p.partyName,
|
||||||
p.partyType,
|
[
|
||||||
p.panNo
|
p.address1,
|
||||||
|
p.address2,
|
||||||
|
p.address3
|
||||||
|
]
|
||||||
|
.filter((x) => !!x)
|
||||||
|
.join(', '),
|
||||||
|
p.city ?? '',
|
||||||
|
p.pincode ?? '',
|
||||||
|
p.phone ?? '',
|
||||||
|
p.fax ?? '',
|
||||||
|
p.bankType ?? '',
|
||||||
|
p.glCode ?? '',
|
||||||
|
p.rbiCode ?? '',
|
||||||
|
p.limit ?? null,
|
||||||
|
p.leiNo ?? '',
|
||||||
|
p.cpBankName ?? '',
|
||||||
|
p.cpBranchName ?? '',
|
||||||
|
p.cpAccountNo ?? '',
|
||||||
|
p.cpIfsc ?? '',
|
||||||
|
p.status ?? 'ACTIVE'
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
case 'script':
|
case 'script':
|
||||||
return one(
|
return one(
|
||||||
[
|
[
|
||||||
'Script Code',
|
'Script Code',
|
||||||
'Script Name',
|
'Bond Type',
|
||||||
'Instrument',
|
'SLR',
|
||||||
|
'Script Desc',
|
||||||
|
'GL A/c No',
|
||||||
'ISIN',
|
'ISIN',
|
||||||
'Face Value/Unit'
|
'Int Rate %',
|
||||||
|
'Issue Date',
|
||||||
|
'Maturity Date',
|
||||||
|
'Period (Y/M/D)',
|
||||||
|
'Face Value/Unit',
|
||||||
|
'Company Name',
|
||||||
|
'Corp Code'
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
14,
|
||||||
16,
|
16,
|
||||||
|
12,
|
||||||
30,
|
30,
|
||||||
|
12,
|
||||||
16,
|
16,
|
||||||
16,
|
10,
|
||||||
16
|
14,
|
||||||
|
14,
|
||||||
|
14,
|
||||||
|
14,
|
||||||
|
24,
|
||||||
|
12
|
||||||
],
|
],
|
||||||
this.scripts.map((s) => [
|
this.scripts.map((s) => {
|
||||||
|
const p = periodBetween(s.issueDate, s.maturityDate);
|
||||||
|
return [
|
||||||
s.scriptCode,
|
s.scriptCode,
|
||||||
s.scriptName,
|
|
||||||
s.instrumentType,
|
s.instrumentType,
|
||||||
|
s.slrType ?? '',
|
||||||
|
s.scriptName,
|
||||||
|
s.glCode ?? '',
|
||||||
s.isin,
|
s.isin,
|
||||||
s.faceValuePerUnit
|
s.couponRate ?? null,
|
||||||
])
|
s.issueDate ?? '',
|
||||||
|
s.maturityDate ?? '',
|
||||||
|
s.issueDate && s.maturityDate ? `${p.years}/${p.months}/${p.days}` : '',
|
||||||
|
s.faceValuePerUnit,
|
||||||
|
s.companyName ?? '',
|
||||||
|
s.corpCode ?? ''
|
||||||
|
];
|
||||||
|
})
|
||||||
);
|
);
|
||||||
case 'subtype':
|
case 'subtype':
|
||||||
return one(
|
return one(
|
||||||
|
|||||||
@@ -12,17 +12,26 @@
|
|||||||
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
|
<div class="pg-eyebrow">TREASURY · INVESTMENT · REPORTS</div>
|
||||||
<h1 class="pg-title">Investment Reports</h1>
|
<h1 class="pg-title">Investment Reports</h1>
|
||||||
<p class="pg-sub">
|
<p class="pg-sub">
|
||||||
Balance reports, ledgers, registers, maturity lists and the consolidated portfolio — all derived live from the
|
Balance reports, ledgers, registers, maturity lists and the consolidated portfolio from the saved deals, FDs and
|
||||||
same holdings/transactions used by the Entry screens.
|
their GL entries, as on {{ asOnDate }}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="pg-toolbar">
|
<div class="pg-toolbar">
|
||||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating" (click)="exportExcel()">
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="loading" (click)="load()">
|
||||||
|
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="generating || loading" (click)="exportExcel()">
|
||||||
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
{{ generating ? 'Exporting…' : 'Export to Excel' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
@if (error) {
|
||||||
|
<div class="pg-card">
|
||||||
|
<p class="inv-hint inv-hint--bad">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="cctr-tabs">
|
<div class="cctr-tabs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -124,7 +133,7 @@
|
|||||||
: activeReport === 'shareBalance'
|
: activeReport === 'shareBalance'
|
||||||
? shareHoldings
|
? shareHoldings
|
||||||
: fdHoldings;
|
: fdHoldings;
|
||||||
track h.certNo
|
track h.instrumentType + ':' + h.id
|
||||||
) {
|
) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -138,6 +147,12 @@
|
|||||||
<span [class]="statusClass(h)">{{ h.status }}</span>
|
<span [class]="statusClass(h)">{{ h.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="pg-empty">
|
||||||
|
{{ activeReport === 'shareBalance' ? 'Share entry is not live yet.' : 'No holdings.' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -280,7 +295,7 @@
|
|||||||
: activeReport === 'amortizeLedger'
|
: activeReport === 'amortizeLedger'
|
||||||
? amortizeTxns
|
? amortizeTxns
|
||||||
: dealSlips;
|
: dealSlips;
|
||||||
track t.voucherNo
|
track $index
|
||||||
) {
|
) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ t.txnDate }}</td>
|
<td>{{ t.txnDate }}</td>
|
||||||
@@ -303,7 +318,10 @@
|
|||||||
}
|
}
|
||||||
@if (activeReport === 'intDueRegister') {
|
@if (activeReport === 'intDueRegister') {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">Interest Due Register (Non-Cumulative Holdings)</div>
|
<div class="pos-section-label">
|
||||||
|
Interest Due Register — interest earned up to {{ asOnDate }} and not received yet ·
|
||||||
|
<a routerLink="/investment/diary">Due Date Diary</a>
|
||||||
|
</div>
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -311,11 +329,11 @@
|
|||||||
<th>Script / Party</th>
|
<th>Script / Party</th>
|
||||||
<th>Frequency</th>
|
<th>Frequency</th>
|
||||||
<th class="num">Rate</th>
|
<th class="num">Rate</th>
|
||||||
<th class="num col-in">Due Amount (as on {{ asOnDate }})</th>
|
<th class="num col-in">Receivable (as on {{ asOnDate }})</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (h of interestDueRegister; track h.certNo) {
|
@for (h of interestDueRegister; track h.instrumentType + ':' + h.id) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ h.certNo }}</span>
|
<span class="pg-chip">{{ h.certNo }}</span>
|
||||||
@@ -325,6 +343,10 @@
|
|||||||
<td class="num">{{ h.interestRate }}%</td>
|
<td class="num">{{ h.interestRate }}%</td>
|
||||||
<td class="num col-in">₹{{ h.dueAmount | number: '1.2-2' }}</td>
|
<td class="num col-in">₹{{ h.dueAmount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="pg-empty">No interest receivable.</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -344,7 +366,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (h of slrBondRegister; track h.certNo) {
|
@for (h of slrBondRegister; track h.instrumentType + ':' + h.id) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ h.certNo }}</span>
|
<span class="pg-chip">{{ h.certNo }}</span>
|
||||||
@@ -372,29 +394,33 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (t of receivedInterestTxns; track t.voucherNo) {
|
@for (t of receivedInterestTxns; track $index) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ t.txnDate }}</td>
|
<td>{{ t.txnDate }}</td>
|
||||||
<td>{{ t.certNo }}</td>
|
<td>{{ t.certNo }}</td>
|
||||||
<td>{{ t.particulars }}</td>
|
<td>{{ t.particulars }}</td>
|
||||||
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
|
<td class="num col-in">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="pg-empty">No interest received yet.</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">Pending Interest (No Interest Posted Yet)</div>
|
<div class="pos-section-label">Pending Interest — earned up to {{ asOnDate }}, not received yet</div>
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Cert No</th>
|
<th>Cert No</th>
|
||||||
<th>Script / Party</th>
|
<th>Script / Party</th>
|
||||||
<th class="num col-out">Accrued (Pending)</th>
|
<th class="num col-out">Receivable</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (h of pendingInterestHoldings; track h.certNo) {
|
@for (h of pendingInterestHoldings; track h.instrumentType + ':' + h.id) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ h.certNo }}</span>
|
<span class="pg-chip">{{ h.certNo }}</span>
|
||||||
@@ -487,11 +513,11 @@
|
|||||||
<th>Cert No</th>
|
<th>Cert No</th>
|
||||||
<th>Party</th>
|
<th>Party</th>
|
||||||
<th class="num">Face Value</th>
|
<th class="num">Face Value</th>
|
||||||
<th>Matured On</th>
|
<th>Maturity Date</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (h of maturedFdList; track h.certNo) {
|
@for (h of maturedFdList; track h.instrumentType + ':' + h.id) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ h.certNo }}</span>
|
<span class="pg-chip">{{ h.certNo }}</span>
|
||||||
@@ -524,7 +550,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (h of maturingList; track h.certNo) {
|
@for (h of maturingList; track h.instrumentType + ':' + h.id) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ h.certNo }}</span>
|
<span class="pg-chip">{{ h.certNo }}</span>
|
||||||
@@ -543,24 +569,12 @@
|
|||||||
@if (activeReport === 'maturityPattern') {
|
@if (activeReport === 'maturityPattern') {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">Maturity Pattern Register</div>
|
<div class="pos-section-label">Maturity Pattern Register</div>
|
||||||
<table class="pg-table">
|
<p class="pg-sub" style="padding: 0 16px">
|
||||||
<thead>
|
The maturity pattern (up to 1 year, 1–3, 3–5 and over 5 years) is the Bucket Wise Investment report.
|
||||||
<tr>
|
</p>
|
||||||
<th>Bucket</th>
|
<div class="inv-form-actions" style="padding: 8px 16px">
|
||||||
<th class="num">Holdings</th>
|
<a class="pg-btn pg-btn--primary" routerLink="/investment/bucket-report">Open Bucket Wise Investment</a>
|
||||||
<th class="num">Face Value</th>
|
</div>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (b of maturityBuckets; track b.label) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ b.label }}</td>
|
|
||||||
<td class="num">{{ b.count }}</td>
|
|
||||||
<td class="num">₹{{ b.faceValue | number: '1.2-2' }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (
|
@if (
|
||||||
@@ -601,7 +615,7 @@
|
|||||||
: activeReport === 'shareSecList'
|
: activeReport === 'shareSecList'
|
||||||
? shareSecList
|
? shareSecList
|
||||||
: mutualFundList;
|
: mutualFundList;
|
||||||
track h.certNo
|
track h.instrumentType + ':' + h.id
|
||||||
) {
|
) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -615,6 +629,12 @@
|
|||||||
<span [class]="statusClass(h)">{{ h.status }}</span>
|
<span [class]="statusClass(h)">{{ h.status }}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="pg-empty">
|
||||||
|
{{ activeReport === 'shareSecList' ? 'Share entry is not live yet.' : 'No holdings.' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -622,35 +642,35 @@
|
|||||||
}
|
}
|
||||||
@if (activeReport === 'shiftingReport') {
|
@if (activeReport === 'shiftingReport') {
|
||||||
<div class="pg-card">
|
<div class="pg-card">
|
||||||
<div class="pos-section-label">
|
<div class="pos-section-label">Shifting Report — Govt Securities transferred to another script / category</div>
|
||||||
Shifting Report — SLR Securities with Market Deviation ≥ 1% (AFS/HTM review candidates)
|
|
||||||
</div>
|
|
||||||
<table class="pg-table">
|
<table class="pg-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Cert No</th>
|
<th>Date</th>
|
||||||
<th>Script</th>
|
<th>Deal No</th>
|
||||||
<th class="num">Purchase Rate</th>
|
<th>From</th>
|
||||||
<th class="num">Market Price</th>
|
<th>To</th>
|
||||||
<th class="num">Deviation %</th>
|
<th class="num">Units</th>
|
||||||
|
<th class="num">Book Value</th>
|
||||||
|
<th>Voucher</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (h of shiftingCandidates; track h.certNo) {
|
@for (x of transfers; track x.id) {
|
||||||
<tr>
|
<tr>
|
||||||
|
<td>{{ x.transferDate }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="pg-chip">{{ h.certNo }}</span>
|
<span class="pg-chip">{{ x.dealNo }}</span>
|
||||||
</td>
|
|
||||||
<td>{{ h.scriptName }}</td>
|
|
||||||
<td class="num">{{ h.purchaseRate }}</td>
|
|
||||||
<td class="num">{{ h.marketPrice }}</td>
|
|
||||||
<td class="num" [class.col-in]="h.deviationPct > 0" [class.col-out]="h.deviationPct < 0">
|
|
||||||
{{ h.deviationPct }}%
|
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ x.fromScriptCode }} {{ x.fromScriptDesc }}</td>
|
||||||
|
<td>{{ x.toScriptCode }} {{ x.toScriptDesc }}</td>
|
||||||
|
<td class="num">{{ x.units | number: '1.0-4' }}</td>
|
||||||
|
<td class="num">₹{{ x.bookValue | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ x.voucherNo || '—' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="pg-empty">No securities crossing the 1% deviation threshold today.</td>
|
<td colspan="7" class="pg-empty">No securities shifted.</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -687,7 +707,7 @@
|
|||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="cctr-total-row">
|
<tr class="cctr-total-row">
|
||||||
<td>Total</td>
|
<td>Total</td>
|
||||||
<td class="num"></td>
|
<td class="num">{{ portfolioTotalCount }}</td>
|
||||||
<td class="num">₹{{ portfolioTotalFaceValue | number: '1.2-2' }}</td>
|
<td class="num">₹{{ portfolioTotalFaceValue | number: '1.2-2' }}</td>
|
||||||
<td class="num">₹{{ portfolioTotalAccrued | number: '1.2-2' }}</td>
|
<td class="num">₹{{ portfolioTotalAccrued | number: '1.2-2' }}</td>
|
||||||
<td class="num">₹{{ portfolioTotalMaturity | number: '1.2-2' }}</td>
|
<td class="num">₹{{ portfolioTotalMaturity | number: '1.2-2' }}</td>
|
||||||
|
|||||||
@@ -6,35 +6,44 @@
|
|||||||
* 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 } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { forkJoin, of } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
ALL_HOLDINGS,
|
|
||||||
ALL_TXNS,
|
|
||||||
FD_HOLDINGS,
|
|
||||||
GOVT_SECURITY_HOLDINGS,
|
|
||||||
NON_SLR_HOLDINGS,
|
|
||||||
BOND_HOLDINGS,
|
|
||||||
MUTUAL_FUND_HOLDINGS,
|
|
||||||
SHARE_HOLDINGS,
|
|
||||||
SCRIPT_MARKET_DETAIL,
|
|
||||||
InstrumentType,
|
InstrumentType,
|
||||||
InvestmentHolding,
|
InvestmentHolding,
|
||||||
InvestmentTxn,
|
InvestmentTxn,
|
||||||
INSTRUMENT_LABELS,
|
INSTRUMENT_LABELS,
|
||||||
accruedInterest,
|
|
||||||
maturityAmount,
|
|
||||||
daysToMaturity,
|
daysToMaturity,
|
||||||
|
parseDDMMYYYY,
|
||||||
todayDDMMYYYY
|
todayDDMMYYYY
|
||||||
} from '../investment-data';
|
} from '../investment-data';
|
||||||
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
import { exportWorkbook, ExportSheetSpec } from '../investment-export';
|
||||||
|
import {
|
||||||
|
InvestmentDailyRateDto,
|
||||||
|
InvestmentFdDto,
|
||||||
|
InvestmentFdScheduleDto,
|
||||||
|
InvestmentSecurityTransferDto,
|
||||||
|
InvestmentService,
|
||||||
|
apiErrorMessage,
|
||||||
|
toFdHolding,
|
||||||
|
toFdTxns,
|
||||||
|
toHolding,
|
||||||
|
toInterestTxn,
|
||||||
|
toPurchaseTxn,
|
||||||
|
toSaleTxn,
|
||||||
|
toTransferTxn
|
||||||
|
} from '../investment.service';
|
||||||
|
|
||||||
type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
|
type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ~20 legacy Investment reports, grouped into 4 tabs. Every figure here is derived from the
|
* ~20 legacy Investment reports in 4 tabs, on live data: the saved purchases and FDs as holdings, and their GL
|
||||||
* same ALL_HOLDINGS/ALL_TXNS arrays used by the Entry screens and Dashboard — so a report can
|
* entries (settlements, interest, sales, redemptions, amortisation, transfers, FD placements and closures) as the
|
||||||
* never drift out of sync with what a Purchase/Sell/Interest entry actually recorded.
|
* ledgers. Interest receivable comes from the Interest Received & Receivable report, market rates from the Daily
|
||||||
|
* Script Master.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-investment-reports',
|
selector: 'mifosx-investment-reports',
|
||||||
@@ -44,37 +53,176 @@ type ReportGroup = 'balance' | 'ledger' | 'maturity' | 'portfolio';
|
|||||||
'../investment-shared.scss'
|
'../investment-shared.scss'
|
||||||
],
|
],
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule]
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterLink
|
||||||
|
]
|
||||||
})
|
})
|
||||||
export class InvestmentReportsComponent {
|
export class InvestmentReportsComponent implements OnInit {
|
||||||
|
private readonly investmentService = inject(InvestmentService);
|
||||||
|
|
||||||
activeGroup: ReportGroup = 'balance';
|
activeGroup: ReportGroup = 'balance';
|
||||||
activeReport = 'govtSecBalance';
|
activeReport = 'govtSecBalance';
|
||||||
asOnDate = todayDDMMYYYY();
|
asOnDate = todayDDMMYYYY();
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
generating = false;
|
generating = false;
|
||||||
toastMsg = '';
|
toastMsg = '';
|
||||||
toastVisible = false;
|
toastVisible = false;
|
||||||
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
private toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
/** Saved purchases and FDs as holdings, and their GL entries, newest first. */
|
||||||
|
holdings: InvestmentHolding[] = [];
|
||||||
|
txns: InvestmentTxn[] = [];
|
||||||
|
/** Coupons and FD interest paid out — interest actually received. */
|
||||||
|
receivedTxns: InvestmentTxn[] = [];
|
||||||
|
/** GOI category transfers (security shifted to another script / category). */
|
||||||
|
transfers: InvestmentSecurityTransferDto[] = [];
|
||||||
|
/** FD interest lines need one call per FD, so they load when a report needs them. */
|
||||||
|
fdInterestLoaded = false;
|
||||||
|
private fds: InvestmentFdDto[] = [];
|
||||||
|
private receivable = new Map<string, number>();
|
||||||
|
private fdMaturity = new Map<number, number>();
|
||||||
|
private latestRate = new Map<string, number>();
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
const d = parseDDMMYYYY(this.asOnDate);
|
||||||
|
const fyStart = `01-04-${d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1}`;
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.fdInterestLoaded = false;
|
||||||
|
forkJoin({
|
||||||
|
purchases: this.investmentService.getPurchases(),
|
||||||
|
fds: this.investmentService.getFds(),
|
||||||
|
sales: this.investmentService.getSales(),
|
||||||
|
postings: this.investmentService.getInterestPostings(),
|
||||||
|
transfers: this.investmentService.getTransfers(),
|
||||||
|
interest: this.investmentService.getInterestReport(fyStart, this.asOnDate),
|
||||||
|
rates: this.investmentService.getDailyRates().pipe(catchError(() => of([] as InvestmentDailyRateDto[])))
|
||||||
|
}).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.fds = r.fds;
|
||||||
|
this.transfers = r.transfers;
|
||||||
|
this.holdings = [
|
||||||
|
...r.purchases.map(toHolding),
|
||||||
|
...r.fds.map(toFdHolding)
|
||||||
|
];
|
||||||
|
this.txns = this.byDateDesc([
|
||||||
|
...r.purchases.map(toPurchaseTxn),
|
||||||
|
...r.postings.map(toInterestTxn),
|
||||||
|
...r.sales.map(toSaleTxn),
|
||||||
|
...r.transfers.map(toTransferTxn),
|
||||||
|
...r.fds.flatMap((f) => toFdTxns(f, []))
|
||||||
|
]);
|
||||||
|
this.receivedTxns = this.byDateDesc(r.postings.filter((x) => x.lineType === 'COUPON').map(toInterestTxn));
|
||||||
|
this.receivable = new Map(
|
||||||
|
r.interest.rows.map((x) => [
|
||||||
|
x.instrument + ':' + x.refId,
|
||||||
|
x.receivable
|
||||||
|
])
|
||||||
|
);
|
||||||
|
this.fdMaturity = new Map(
|
||||||
|
r.fds
|
||||||
|
.filter((f) => f.id != null)
|
||||||
|
.map((f) => [
|
||||||
|
f.id as number,
|
||||||
|
f.expectedMaturityAmount ?? f.principal ?? 0
|
||||||
|
])
|
||||||
|
);
|
||||||
|
// Latest close on or before the as-on date, per script.
|
||||||
|
const asOn = parseDDMMYYYY(this.asOnDate).getTime();
|
||||||
|
this.latestRate = new Map();
|
||||||
|
[...r.rates]
|
||||||
|
.filter((x) => parseDDMMYYYY(x.rateDate).getTime() <= asOn)
|
||||||
|
.sort((a, b) => parseDDMMYYYY(a.rateDate).getTime() - parseDDMMYYYY(b.rateDate).getTime())
|
||||||
|
.forEach((x) => this.latestRate.set(x.scriptCode, x.closeRate));
|
||||||
|
this.loading = false;
|
||||||
|
if (this.activeReport === 'fdLedger' || this.activeReport === 'pendingReceived') {
|
||||||
|
this.loadFdInterest();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (err: unknown) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.error = apiErrorMessage(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds each FD's posted interest lines to the ledgers (one schedule call per FD). */
|
||||||
|
private loadFdInterest(): void {
|
||||||
|
if (this.fdInterestLoaded || !this.fds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.fdInterestLoaded = true;
|
||||||
|
const withId = this.fds.filter((f) => f.id != null);
|
||||||
|
forkJoin(
|
||||||
|
withId.map((f) =>
|
||||||
|
this.investmentService
|
||||||
|
.getFdSchedule(f.id as number)
|
||||||
|
.pipe(catchError(() => of(null as InvestmentFdScheduleDto | null)))
|
||||||
|
)
|
||||||
|
).subscribe((schedules) => {
|
||||||
|
const lines: InvestmentTxn[] = [];
|
||||||
|
const received: InvestmentTxn[] = [];
|
||||||
|
schedules.forEach((s, i) => {
|
||||||
|
if (!s) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const interest = toFdTxns(withId[i], s.rows).filter((t) => t.txnType === 'INTEREST');
|
||||||
|
lines.push(...interest);
|
||||||
|
received.push(...interest.filter((t) => !t.particulars.startsWith('Provision')));
|
||||||
|
});
|
||||||
|
this.txns = this.byDateDesc([
|
||||||
|
...this.txns,
|
||||||
|
...lines
|
||||||
|
]);
|
||||||
|
this.receivedTxns = this.byDateDesc([
|
||||||
|
...this.receivedTxns,
|
||||||
|
...received
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private byDateDesc(txns: InvestmentTxn[]): InvestmentTxn[] {
|
||||||
|
return txns
|
||||||
|
.filter((t) => !!t.txnDate)
|
||||||
|
.sort((a, b) => parseDDMMYYYY(b.txnDate).getTime() - parseDDMMYYYY(a.txnDate).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
setGroup(group: ReportGroup, defaultReport: string): void {
|
setGroup(group: ReportGroup, defaultReport: string): void {
|
||||||
this.activeGroup = group;
|
this.activeGroup = group;
|
||||||
this.activeReport = defaultReport;
|
this.setReport(defaultReport);
|
||||||
}
|
}
|
||||||
|
|
||||||
setReport(report: string): void {
|
setReport(report: string): void {
|
||||||
this.activeReport = report;
|
this.activeReport = report;
|
||||||
|
if (report === 'fdLedger' || report === 'pendingReceived') {
|
||||||
|
this.loadFdInterest();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
instrumentLabel(type: InstrumentType): string {
|
instrumentLabel(type: InstrumentType): string {
|
||||||
return INSTRUMENT_LABELS[type];
|
return INSTRUMENT_LABELS[type];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Interest earned up to the as-on date and not yet received (Interest Received & Receivable report). */
|
||||||
accrued(h: InvestmentHolding): number {
|
accrued(h: InvestmentHolding): number {
|
||||||
return accruedInterest(h, this.asOnDate);
|
return this.receivable.get((h.instrumentType === 'FD' ? 'FD' : 'SECURITY') + ':' + h.id) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What comes back at maturity: the FD maturity amount, a security's face value, a fund's cost. */
|
||||||
maturity(h: InvestmentHolding): number {
|
maturity(h: InvestmentHolding): number {
|
||||||
return maturityAmount(h);
|
if (h.instrumentType === 'FD') {
|
||||||
|
return this.fdMaturity.get(h.id ?? -1) ?? h.faceValue;
|
||||||
|
}
|
||||||
|
return h.instrumentType === 'MUTUAL_FUND' || h.instrumentType === 'SHARE'
|
||||||
|
? (h.bookValue ?? h.faceValue)
|
||||||
|
: h.faceValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
daysLeft(h: InvestmentHolding): number {
|
daysLeft(h: InvestmentHolding): number {
|
||||||
@@ -85,23 +233,32 @@ export class InvestmentReportsComponent {
|
|||||||
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
return `inv-badge inv-badge--${h.status.toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Latest close in the Daily Script Master on or before the as-on date. */
|
||||||
marketPrice(scriptCode: string): number | null {
|
marketPrice(scriptCode: string): number | null {
|
||||||
return SCRIPT_MARKET_DETAIL.find((m) => m.scriptCode === scriptCode)?.marketPrice ?? null;
|
return this.latestRate.get(scriptCode) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private active(type?: InstrumentType): InvestmentHolding[] {
|
||||||
|
return this.holdings.filter((h) => h.status === 'ACTIVE' && (!type || h.instrumentType === type));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ofType(type: InstrumentType): InvestmentHolding[] {
|
||||||
|
return this.holdings.filter((h) => h.instrumentType === type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Balance Reports --------------------
|
// -------------------- Balance Reports --------------------
|
||||||
get govtSecHoldings(): InvestmentHolding[] {
|
get govtSecHoldings(): InvestmentHolding[] {
|
||||||
return GOVT_SECURITY_HOLDINGS;
|
return this.active('GOVT_SECURITY');
|
||||||
}
|
}
|
||||||
get shareHoldings(): InvestmentHolding[] {
|
get shareHoldings(): InvestmentHolding[] {
|
||||||
return SHARE_HOLDINGS;
|
return this.active('SHARE');
|
||||||
}
|
}
|
||||||
get fdHoldings(): InvestmentHolding[] {
|
get fdHoldings(): InvestmentHolding[] {
|
||||||
return FD_HOLDINGS;
|
return this.active('FD');
|
||||||
}
|
}
|
||||||
get bankInvestmentList(): Array<{ party: string; count: number; totalFaceValue: number }> {
|
get bankInvestmentList(): Array<{ party: string; count: number; totalFaceValue: number }> {
|
||||||
const map = new Map<string, { party: string; count: number; totalFaceValue: number }>();
|
const map = new Map<string, { party: string; count: number; totalFaceValue: number }>();
|
||||||
for (const h of ALL_HOLDINGS) {
|
for (const h of this.active()) {
|
||||||
const row = map.get(h.party) || { party: h.party, count: 0, totalFaceValue: 0 };
|
const row = map.get(h.party) || { party: h.party, count: 0, totalFaceValue: 0 };
|
||||||
row.count += 1;
|
row.count += 1;
|
||||||
row.totalFaceValue += h.faceValue;
|
row.totalFaceValue += h.faceValue;
|
||||||
@@ -112,112 +269,68 @@ export class InvestmentReportsComponent {
|
|||||||
|
|
||||||
// -------------------- Ledgers & Registers --------------------
|
// -------------------- Ledgers & Registers --------------------
|
||||||
get fdTxns(): InvestmentTxn[] {
|
get fdTxns(): InvestmentTxn[] {
|
||||||
return ALL_TXNS.filter((t) => t.instrumentType === 'FD');
|
return this.txns.filter((t) => t.instrumentType === 'FD');
|
||||||
}
|
}
|
||||||
get bondTxns(): InvestmentTxn[] {
|
get bondTxns(): InvestmentTxn[] {
|
||||||
return ALL_TXNS.filter((t) => ([
|
return this.txns.filter((t) => ([
|
||||||
'GOVT_SECURITY',
|
'GOVT_SECURITY',
|
||||||
'NON_SLR',
|
'NON_SLR',
|
||||||
'BOND'
|
'BOND',
|
||||||
|
'MUTUAL_FUND'
|
||||||
] as InstrumentType[]).includes(t.instrumentType));
|
] as InstrumentType[]).includes(t.instrumentType));
|
||||||
}
|
}
|
||||||
get shareTxns(): InvestmentTxn[] {
|
get shareTxns(): InvestmentTxn[] {
|
||||||
return ALL_TXNS.filter((t) => t.instrumentType === 'SHARE');
|
return this.txns.filter((t) => t.instrumentType === 'SHARE');
|
||||||
}
|
}
|
||||||
get amortizeTxns(): InvestmentTxn[] {
|
get amortizeTxns(): InvestmentTxn[] {
|
||||||
return ALL_TXNS.filter((t) => t.txnType === 'AMORTIZE');
|
return this.txns.filter((t) => t.txnType === 'AMORTIZE');
|
||||||
}
|
}
|
||||||
get dealSlips(): InvestmentTxn[] {
|
get dealSlips(): InvestmentTxn[] {
|
||||||
return ALL_TXNS.filter((t) => t.txnType === 'PURCHASE');
|
return this.txns.filter((t) => t.txnType === 'PURCHASE' && t.instrumentType !== 'FD');
|
||||||
}
|
}
|
||||||
get slrBondRegister(): InvestmentHolding[] {
|
get slrBondRegister(): InvestmentHolding[] {
|
||||||
return GOVT_SECURITY_HOLDINGS.filter((h) => h.subType === 'SLR');
|
return this.active('GOVT_SECURITY');
|
||||||
}
|
}
|
||||||
|
/** Holdings with interest earned up to the as-on date and not received yet, with the amount. */
|
||||||
get interestDueRegister(): Array<InvestmentHolding & { dueAmount: number }> {
|
get interestDueRegister(): Array<InvestmentHolding & { dueAmount: number }> {
|
||||||
return ALL_HOLDINGS.filter((h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE').map((h) => ({
|
return this.active()
|
||||||
...h,
|
.map((h) => ({ ...h, dueAmount: this.accrued(h) }))
|
||||||
dueAmount: this.accrued(h)
|
.filter((h) => h.dueAmount > 0);
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
get receivedInterestTxns(): InvestmentTxn[] {
|
get receivedInterestTxns(): InvestmentTxn[] {
|
||||||
return ALL_TXNS.filter((t) => t.txnType === 'INTEREST' || t.txnType === 'DIVIDEND');
|
return this.receivedTxns;
|
||||||
}
|
}
|
||||||
get pendingInterestHoldings(): InvestmentHolding[] {
|
get pendingInterestHoldings(): InvestmentHolding[] {
|
||||||
const receivedCerts = new Set(this.receivedInterestTxns.map((t) => t.certNo));
|
return this.active().filter((h) => this.accrued(h) > 0);
|
||||||
return ALL_HOLDINGS.filter(
|
|
||||||
(h) => h.status === 'ACTIVE' && h.interestFreq !== 'CUMULATIVE' && !receivedCerts.has(h.certNo)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Maturity & Lists --------------------
|
// -------------------- Maturity & Lists --------------------
|
||||||
get maturedFdList(): InvestmentHolding[] {
|
get maturedFdList(): InvestmentHolding[] {
|
||||||
return FD_HOLDINGS.filter((h) => h.status === 'MATURED');
|
return this.ofType('FD').filter((h) => h.status === 'MATURED');
|
||||||
}
|
}
|
||||||
get maturingList(): InvestmentHolding[] {
|
get maturingList(): InvestmentHolding[] {
|
||||||
return [
|
return [
|
||||||
...FD_HOLDINGS,
|
...this.active('FD'),
|
||||||
...GOVT_SECURITY_HOLDINGS
|
...this.active('GOVT_SECURITY')
|
||||||
]
|
].sort((a, b) => this.daysLeft(a) - this.daysLeft(b));
|
||||||
.filter((h) => h.status === 'ACTIVE')
|
|
||||||
.sort((a, b) => this.daysLeft(a) - this.daysLeft(b));
|
|
||||||
}
|
|
||||||
get maturityBuckets(): Array<{ label: string; count: number; faceValue: number }> {
|
|
||||||
const buckets = [
|
|
||||||
{ label: '0-1 Year', min: -99999, max: 365, count: 0, faceValue: 0 },
|
|
||||||
{ label: '1-3 Years', min: 366, max: 1095, count: 0, faceValue: 0 },
|
|
||||||
{ label: '3-5 Years', min: 1096, max: 1825, count: 0, faceValue: 0 },
|
|
||||||
{ label: 'Above 5 Years', min: 1826, max: 999999, count: 0, faceValue: 0 }
|
|
||||||
];
|
|
||||||
for (const h of ALL_HOLDINGS.filter((x) => x.status === 'ACTIVE')) {
|
|
||||||
const days = this.daysLeft(h);
|
|
||||||
const bucket = buckets.find((b) => days >= b.min && days <= b.max);
|
|
||||||
if (bucket) {
|
|
||||||
bucket.count += 1;
|
|
||||||
bucket.faceValue += h.faceValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buckets;
|
|
||||||
}
|
}
|
||||||
get govtSecList(): InvestmentHolding[] {
|
get govtSecList(): InvestmentHolding[] {
|
||||||
return GOVT_SECURITY_HOLDINGS;
|
return this.ofType('GOVT_SECURITY');
|
||||||
}
|
}
|
||||||
get fdSecList(): InvestmentHolding[] {
|
get fdSecList(): InvestmentHolding[] {
|
||||||
return FD_HOLDINGS;
|
return this.ofType('FD');
|
||||||
}
|
}
|
||||||
get shareSecList(): InvestmentHolding[] {
|
get shareSecList(): InvestmentHolding[] {
|
||||||
return SHARE_HOLDINGS;
|
return this.ofType('SHARE');
|
||||||
}
|
}
|
||||||
get mutualFundList(): InvestmentHolding[] {
|
get mutualFundList(): InvestmentHolding[] {
|
||||||
return MUTUAL_FUND_HOLDINGS;
|
return this.ofType('MUTUAL_FUND');
|
||||||
}
|
|
||||||
get nonSlrList(): InvestmentHolding[] {
|
|
||||||
return NON_SLR_HOLDINGS;
|
|
||||||
}
|
|
||||||
get bondList(): InvestmentHolding[] {
|
|
||||||
return BOND_HOLDINGS;
|
|
||||||
}
|
|
||||||
/** Shifting Report — SLR securities whose market value has moved away from book value by
|
|
||||||
* more than 1%, a proxy for "category shift" candidates (AFS/HTM reclassification review). */
|
|
||||||
get shiftingCandidates(): Array<InvestmentHolding & { marketPrice: number; deviationPct: number }> {
|
|
||||||
return GOVT_SECURITY_HOLDINGS.map((h) => {
|
|
||||||
const mp = this.marketPrice(h.scriptCode);
|
|
||||||
if (mp === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const deviationPct = +(((mp - h.purchaseRate) / h.purchaseRate) * 100).toFixed(2);
|
|
||||||
return { ...h, marketPrice: mp, deviationPct };
|
|
||||||
})
|
|
||||||
.filter(
|
|
||||||
(x): x is InvestmentHolding & { marketPrice: number; deviationPct: number } =>
|
|
||||||
x !== null && Math.abs(x.deviationPct) >= 1
|
|
||||||
)
|
|
||||||
.sort((a, b) => Math.abs(b.deviationPct) - Math.abs(a.deviationPct));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------- Portfolio --------------------
|
// -------------------- Portfolio --------------------
|
||||||
private readonly portfolioTypes: InstrumentType[] = [
|
private readonly portfolioTypes: InstrumentType[] = [
|
||||||
'FD',
|
|
||||||
'GOVT_SECURITY',
|
'GOVT_SECURITY',
|
||||||
|
'FD',
|
||||||
'NON_SLR',
|
'NON_SLR',
|
||||||
'BOND',
|
'BOND',
|
||||||
'MUTUAL_FUND',
|
'MUTUAL_FUND',
|
||||||
@@ -231,7 +344,7 @@ export class InvestmentReportsComponent {
|
|||||||
maturityValue: number;
|
maturityValue: number;
|
||||||
}> {
|
}> {
|
||||||
return this.portfolioTypes.map((type) => {
|
return this.portfolioTypes.map((type) => {
|
||||||
const holdings = ALL_HOLDINGS.filter((h) => h.instrumentType === type);
|
const holdings = this.active(type);
|
||||||
return {
|
return {
|
||||||
label: INSTRUMENT_LABELS[type],
|
label: INSTRUMENT_LABELS[type],
|
||||||
count: holdings.length,
|
count: holdings.length,
|
||||||
@@ -241,6 +354,9 @@ export class InvestmentReportsComponent {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
get portfolioTotalCount(): number {
|
||||||
|
return this.portfolioSummary.reduce((s, r) => s + r.count, 0);
|
||||||
|
}
|
||||||
get portfolioTotalFaceValue(): number {
|
get portfolioTotalFaceValue(): number {
|
||||||
return this.portfolioSummary.reduce((s, r) => s + r.faceValue, 0);
|
return this.portfolioSummary.reduce((s, r) => s + r.faceValue, 0);
|
||||||
}
|
}
|
||||||
@@ -480,26 +596,6 @@ export class InvestmentReportsComponent {
|
|||||||
this.holdingsExportWidths,
|
this.holdingsExportWidths,
|
||||||
this.maturingList.map((h) => this.holdingsExportRow(h))
|
this.maturingList.map((h) => this.holdingsExportRow(h))
|
||||||
);
|
);
|
||||||
case 'maturityPattern':
|
|
||||||
return one(
|
|
||||||
'Maturity Pattern',
|
|
||||||
'Maturity Pattern Register',
|
|
||||||
[
|
|
||||||
'Bucket',
|
|
||||||
'Holdings',
|
|
||||||
'Face Value'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
22,
|
|
||||||
14,
|
|
||||||
18
|
|
||||||
],
|
|
||||||
this.maturityBuckets.map((b) => [
|
|
||||||
b.label,
|
|
||||||
b.count,
|
|
||||||
b.faceValue
|
|
||||||
])
|
|
||||||
);
|
|
||||||
case 'govtSecList':
|
case 'govtSecList':
|
||||||
return one(
|
return one(
|
||||||
'Govt Security List',
|
'Govt Security List',
|
||||||
@@ -535,27 +631,33 @@ export class InvestmentReportsComponent {
|
|||||||
case 'shiftingReport':
|
case 'shiftingReport':
|
||||||
return one(
|
return one(
|
||||||
'Shifting Report',
|
'Shifting Report',
|
||||||
'Shifting Report — SLR Market Deviation ≥ 1%',
|
'Shifting Report — Govt Securities transferred to another script / category',
|
||||||
[
|
[
|
||||||
'Cert No',
|
'Date',
|
||||||
'Script',
|
'Deal No',
|
||||||
'Purchase Rate',
|
'From',
|
||||||
'Market Price',
|
'To',
|
||||||
'Deviation %'
|
'Units',
|
||||||
|
'Book Value',
|
||||||
|
'Voucher No'
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
14,
|
12,
|
||||||
30,
|
|
||||||
16,
|
16,
|
||||||
|
34,
|
||||||
|
34,
|
||||||
|
12,
|
||||||
16,
|
16,
|
||||||
14
|
20
|
||||||
],
|
],
|
||||||
this.shiftingCandidates.map((x) => [
|
this.transfers.map((x) => [
|
||||||
x.certNo,
|
x.transferDate,
|
||||||
x.scriptName,
|
x.dealNo,
|
||||||
x.purchaseRate,
|
`${x.fromScriptCode} ${x.fromScriptDesc}`,
|
||||||
x.marketPrice,
|
`${x.toScriptCode} ${x.toScriptDesc}`,
|
||||||
x.deviationPct
|
x.units,
|
||||||
|
x.bookValue,
|
||||||
|
x.voucherNo ?? ''
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
case 'portfolio':
|
case 'portfolio':
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { ShareInvestmentComponent } from './share-investment/share-investment.co
|
|||||||
import { InvestmentMastersComponent } from './investment-masters/investment-masters.component';
|
import { InvestmentMastersComponent } from './investment-masters/investment-masters.component';
|
||||||
import { InvestmentReportsComponent } from './investment-reports/investment-reports.component';
|
import { InvestmentReportsComponent } from './investment-reports/investment-reports.component';
|
||||||
import { InvestmentInterestComponent } from './investment-interest/investment-interest.component';
|
import { InvestmentInterestComponent } from './investment-interest/investment-interest.component';
|
||||||
|
import { InvestmentDiaryComponent } from './investment-diary/investment-diary.component';
|
||||||
|
import { InvestmentInterestReportComponent } from './investment-interest-report/investment-interest-report.component';
|
||||||
|
import { InvestmentBucketReportComponent } from './investment-bucket-report/investment-bucket-report.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -54,6 +57,21 @@ const routes: Routes = [
|
|||||||
component: InvestmentInterestComponent,
|
component: InvestmentInterestComponent,
|
||||||
data: { title: 'Investment Interest', breadcrumb: 'Investment Interest' }
|
data: { title: 'Investment Interest', breadcrumb: 'Investment Interest' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'diary',
|
||||||
|
component: InvestmentDiaryComponent,
|
||||||
|
data: { title: 'Due Date Diary', breadcrumb: 'Due Date Diary' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'interest-report',
|
||||||
|
component: InvestmentInterestReportComponent,
|
||||||
|
data: { title: 'Interest Received & Receivable', breadcrumb: 'Interest Received & Receivable' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'bucket-report',
|
||||||
|
component: InvestmentBucketReportComponent,
|
||||||
|
data: { title: 'Bucket Wise Investment', breadcrumb: 'Bucket Wise Investment' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'reports',
|
path: 'reports',
|
||||||
component: InvestmentReportsComponent,
|
component: InvestmentReportsComponent,
|
||||||
|
|||||||
@@ -260,3 +260,14 @@
|
|||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inv-hint {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #15803d;
|
||||||
|
|
||||||
|
&--bad {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user