Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bf74afe24 | ||
|
|
885b5897af | ||
|
|
f0b8f7f5f4 | ||
|
|
1bf441114e | ||
|
|
896e3fd7e8 | ||
|
|
f9748036c3 | ||
|
|
7eac2dcf3b | ||
|
|
1b70fb4534 |
@@ -139,6 +139,13 @@
|
||||
</td>
|
||||
</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">
|
||||
<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">
|
||||
|
||||
@@ -34,7 +34,7 @@ import { FormatNumberPipe } from '../../pipes/format-number.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { AccountsService } from '../accounts.service';
|
||||
|
||||
export type AccountType = 'savings' | 'current' | 'fixed-deposits' | 'recurring-deposits';
|
||||
export type AccountType = 'savings' | 'current' | 'cc' | 'fixed-deposits' | 'recurring-deposits';
|
||||
|
||||
interface AccountConfig {
|
||||
label: string;
|
||||
@@ -119,6 +119,13 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
typeLabel: 'CA',
|
||||
typeBadgeClass: 'dtype--ca'
|
||||
},
|
||||
cc: {
|
||||
label: 'CC Accounts',
|
||||
icon: 'hand-holding-usd',
|
||||
accentClass: 'accent--current',
|
||||
typeLabel: 'CC',
|
||||
typeBadgeClass: 'dtype--ca'
|
||||
},
|
||||
'fixed-deposits': {
|
||||
label: 'Fixed Deposit Accounts',
|
||||
icon: 'lock',
|
||||
@@ -142,6 +149,24 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
ngOnInit() {
|
||||
this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => {
|
||||
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.searchControl.setValue('', { emitEvent: false });
|
||||
this.loadAccounts();
|
||||
@@ -183,9 +208,12 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
const toVal = this.toDateControl.value;
|
||||
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 = {
|
||||
offset: this.pageIndex * this.pageSize,
|
||||
limit: this.pageSize,
|
||||
offset: isCc ? 0 : this.pageIndex * this.pageSize,
|
||||
limit: isCc ? 500 : this.pageSize,
|
||||
...(isCc && { overdraftOnly: true }),
|
||||
accountNo: this.searchType === 'accountNo' ? search || undefined : undefined,
|
||||
clientName: this.searchType === 'clientName' ? search || undefined : undefined,
|
||||
orderBy: 'id',
|
||||
@@ -199,7 +227,7 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
const obs$ =
|
||||
this.accountType === 'savings'
|
||||
? this.accountsService.getSavingsAccounts(params)
|
||||
: this.accountType === 'current'
|
||||
: this.accountType === 'current' || this.accountType === 'cc'
|
||||
? this.accountsService.getCurrentAccounts(params)
|
||||
: this.accountType === 'fixed-deposits'
|
||||
? this.accountsService.getFixedDepositAccounts(params)
|
||||
@@ -239,7 +267,7 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
this.totalRecords = Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length);
|
||||
this.totalRecords = isCc || Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length);
|
||||
this.dataSource.data = items;
|
||||
this.isLoading = false;
|
||||
},
|
||||
@@ -307,6 +335,13 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
id,
|
||||
'general'
|
||||
],
|
||||
cc: [
|
||||
'/clients',
|
||||
account.clientId,
|
||||
'current-accounts',
|
||||
id,
|
||||
'general'
|
||||
],
|
||||
'fixed-deposits': [
|
||||
'/clients',
|
||||
account.clientId,
|
||||
|
||||
@@ -36,6 +36,12 @@ const routes: Routes = [
|
||||
component: AccountsListComponent,
|
||||
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',
|
||||
component: AccountsListComponent,
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface AccountListParams {
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
asOnDate?: string;
|
||||
/** Current accounts only: just the Cash Credit (overdraft) accounts, filtered by the server. */
|
||||
overdraftOnly?: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -55,6 +57,7 @@ export class AccountsService {
|
||||
if (params.fromDate) httpParams = httpParams.set('fromDate', params.fromDate);
|
||||
if (params.toDate) httpParams = httpParams.set('toDate', params.toDate);
|
||||
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
|
||||
if (params.overdraftOnly) httpParams = httpParams.set('overdraftOnly', 'true');
|
||||
return this.http.get('/currentaccounts', { params: httpParams });
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,16 @@ export class AmlService {
|
||||
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 */
|
||||
generateCtrReport(month: string): Observable<any> {
|
||||
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -0,0 +1,61 @@
|
||||
<!--
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<!--
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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>();
|
||||
}
|
||||
@@ -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/.
|
||||
-->
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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 {}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,777 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* 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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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,10 +73,13 @@ import { InvestmentModule } from './investment/investment.module';
|
||||
import { NpaModule } from './npa/npa.module';
|
||||
import { ReconciliationModule } from './reconciliation/reconciliation.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 { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||
import { AccountsModule } from './accounts/accounts.module';
|
||||
import { ElectionModule } from './election/election.module';
|
||||
import { LoanEligibilityModule } from './loan-eligibility/loan-eligibility.module';
|
||||
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
|
||||
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
|
||||
import { PortalModule } from '@angular/cdk/portal';
|
||||
@@ -189,10 +192,13 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
NpaModule,
|
||||
ReconciliationModule,
|
||||
LoanOriginationModule,
|
||||
CreditBureauModule,
|
||||
ApiManagementModule,
|
||||
DigitalModule,
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
ElectionModule,
|
||||
LoanEligibilityModule,
|
||||
SponsorDDModule,
|
||||
SponsorDDReconciliationModule,
|
||||
AppRoutingModule,
|
||||
|
||||
@@ -132,8 +132,8 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branchSelectOptions.length <= 1"
|
||||
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@for (b of branchSelectOptions; track b.id) {
|
||||
@@ -1626,8 +1626,8 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branchSelectOptions.length <= 1"
|
||||
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@for (b of branchSelectOptions; track b.id) {
|
||||
|
||||
@@ -1880,6 +1880,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
this.loadBranches();
|
||||
this.addRow();
|
||||
this.loadBranchOptions();
|
||||
this.loadDenominations();
|
||||
this.loadOpeningPosition();
|
||||
this.loadDayStatus();
|
||||
@@ -2711,11 +2712,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
branchNameFor(branchId: number): string {
|
||||
return (
|
||||
this.seenBranches.find((b) => b.id === branchId)?.name ??
|
||||
this.branches.find((b) => b.id === branchId)?.label ??
|
||||
`Branch ${branchId}`
|
||||
);
|
||||
const seen = this.seenBranches.find((b) => b.id === branchId);
|
||||
if (seen) return seen.name;
|
||||
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4065,11 +4064,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
if (token !== this.searchToken) return; // stale result — newer search started
|
||||
const found: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
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
|
||||
// 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());
|
||||
@@ -4099,18 +4095,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = false;
|
||||
// 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}`;
|
||||
this.accountError = 'Account not found for: ' + acNo;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4174,10 +4159,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
* 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. */
|
||||
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();
|
||||
if (t === 'LA') {
|
||||
@@ -4258,7 +4239,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/fixeddepositaccounts',
|
||||
depositTypeId: 200,
|
||||
nominee: 'FD',
|
||||
search: 'fixeddeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4276,7 +4256,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/recurringdepositaccounts',
|
||||
depositTypeId: 300,
|
||||
nominee: 'RD',
|
||||
search: 'recurringdeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4294,7 +4273,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/savingsaccounts',
|
||||
depositTypeId: 100,
|
||||
nominee: 'SB',
|
||||
search: 'saving',
|
||||
siAccountType: 2,
|
||||
@@ -4982,11 +4960,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return Number(mine) === Number(accountOffice);
|
||||
}
|
||||
|
||||
/** What the BRANCH selector offers: the offices this login may use, plus the branches of the accounts picked here. */
|
||||
/** The bank's branches, for each row's BRANCH selector. Empty until /offices answers. */
|
||||
branchOptions: { id: number; name: string }[] = [];
|
||||
|
||||
/** What the BRANCH selector offers: the real branches, or the old fixed list while they have not loaded. */
|
||||
get branchSelectOptions(): { id: number; name: string }[] {
|
||||
const merged = this.branches.map(
|
||||
(branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? { id: branch.id, name: branch.label }
|
||||
);
|
||||
const base = this.branchOptions.length ? this.branchOptions : TransactionEntryComponent.LEGACY_BRANCHES;
|
||||
const merged = base.map((branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? branch);
|
||||
for (const seen of this.seenBranches) {
|
||||
if (!merged.some((branch) => branch.id === seen.id)) merged.push(seen);
|
||||
}
|
||||
@@ -4996,6 +4976,27 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
/** Branches learned from the accounts picked on this screen; their names are real, whatever the office list says. */
|
||||
private seenBranches: { id: number; name: string }[] = [];
|
||||
|
||||
private static readonly LEGACY_BRANCHES = [
|
||||
{ id: 1, name: '001 · Main' },
|
||||
{ id: 2, name: '002 · M.G. Road' },
|
||||
{ id: 14, name: '014 · Station Rd' },
|
||||
{ id: 27, name: '027 · Industrial' }
|
||||
];
|
||||
|
||||
/**
|
||||
* The bank's branches for the BRANCH selector. Listing offices needs the READ offices permission, which a cashier or
|
||||
* clerk may not hold, so a refusal must not raise an error popup: the selector then shows the branches this screen has
|
||||
* learned from the accounts on it (and the old fixed list until then).
|
||||
*/
|
||||
private loadBranchOptions(): void {
|
||||
this.http
|
||||
.get<any[]>('/offices', { context: new HttpContext().set(SILENT_HTTP_ERROR, true) })
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((offices) => {
|
||||
this.branchOptions = (offices ?? []).map((o: any) => ({ id: o.id, name: o.name }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch an account is held at. The savings, FD and RD detail responses carry no office, so the screen fell back
|
||||
* to the client's own branch and an account opened at another branch read as the client's. The client's account list
|
||||
|
||||
@@ -356,6 +356,15 @@
|
||||
<button mat-menu-item *mifosxHasPermission="'CREATE_CURRENTACCOUNT'" [routerLink]="['current-accounts', 'create']">
|
||||
{{ 'labels.buttons.New Current Account' | translate }}
|
||||
</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 #Actions="matMenu">
|
||||
|
||||
@@ -846,6 +846,11 @@ export class ClientsService {
|
||||
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 */
|
||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||
@@ -1141,8 +1146,8 @@ export class ClientsService {
|
||||
notes?: string;
|
||||
officerName?: string;
|
||||
sendAt?: string;
|
||||
}): Observable<{ queued: number; skipped: number; unreachable: number }> {
|
||||
return this.http.post<{ queued: number; skipped: number; unreachable: number }>('/kyc/notice-queue', body);
|
||||
}): Observable<{ queued: number; skipped: number }> {
|
||||
return this.http.post<{ queued: number; skipped: number }>('/kyc/notice-queue', body);
|
||||
}
|
||||
|
||||
/** Queued notices, newest first; status narrows to PENDING, SENT, FAILED or CANCELLED. */
|
||||
@@ -1156,63 +1161,9 @@ export class ClientsService {
|
||||
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. */
|
||||
getKycContactAuditDays(
|
||||
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string; real?: boolean } = {}
|
||||
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string } = {}
|
||||
): Observable<any[]> {
|
||||
return this.http.get<any[]>('/kyc/contact-audit/days', {
|
||||
params: this.auditParams(filter),
|
||||
@@ -1228,7 +1179,6 @@ export class ClientsService {
|
||||
channel?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
real?: boolean;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
} = {}
|
||||
@@ -1239,7 +1189,7 @@ export class ClientsService {
|
||||
});
|
||||
}
|
||||
|
||||
private auditParams(filter: Record<string, string | number | boolean | undefined>): HttpParams {
|
||||
private auditParams(filter: Record<string, string | number | undefined>): HttpParams {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filter).forEach(
|
||||
([
|
||||
|
||||
@@ -59,10 +59,7 @@ export class CacheInterceptor implements HttpInterceptor {
|
||||
'/notes',
|
||||
// The ALM maturity breakdown counts deposits and loans as of the date asked and reads the bank's
|
||||
// own bucket edges from the query; a cached copy would show another date's or another setting's figures.
|
||||
'/almreturn',
|
||||
// The Approvals badge: a stream that stays open for minutes and a count that moves with every request raised or
|
||||
// actioned. A copy of either is of no use, and the stream must never be held by anything but its own caller.
|
||||
'/approvals/counts'
|
||||
'/almreturn'
|
||||
];
|
||||
|
||||
private isCacheable(url: string): boolean {
|
||||
@@ -96,22 +93,18 @@ export class CacheInterceptor implements HttpInterceptor {
|
||||
// Create new response to avoid side-effects
|
||||
subscriber.next(new HttpResponse(cachedData as Object));
|
||||
subscriber.complete();
|
||||
return undefined;
|
||||
} else {
|
||||
next.handle(request).subscribe(
|
||||
(event) => {
|
||||
if (environment.httpCacheEnabled && event instanceof HttpResponse) {
|
||||
this.httpCacheService.setCacheData(request.urlWithParams, event);
|
||||
}
|
||||
subscriber.next(event);
|
||||
},
|
||||
(error) => subscriber.error(error),
|
||||
() => subscriber.complete()
|
||||
);
|
||||
}
|
||||
const inner = next.handle(request).subscribe(
|
||||
(event) => {
|
||||
if (environment.httpCacheEnabled && event instanceof HttpResponse) {
|
||||
this.httpCacheService.setCacheData(request.urlWithParams, event);
|
||||
}
|
||||
subscriber.next(event);
|
||||
},
|
||||
(error) => subscriber.error(error),
|
||||
() => subscriber.complete()
|
||||
);
|
||||
// The request is cancelled when its caller walks away (a page is left, a screen is closed). The inner request has to
|
||||
// be cancelled with it: left to run, it kept its connection open. A browser holds about six per site, so a few
|
||||
// streams that were let go but never closed filled them and every later request waited in the queue.
|
||||
return () => inner.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +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 { 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,7 +16,6 @@ import { finalize } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
import { ProgressBarService } from './progress-bar.service';
|
||||
import { NO_PROGRESS_BAR } from './no-progress.token';
|
||||
|
||||
/**
|
||||
* Http Request interceptor to start/stop loading the progress bar.
|
||||
@@ -30,9 +29,6 @@ export class ProgressInterceptor implements HttpInterceptor {
|
||||
* and stop when a response or error is received.
|
||||
*/
|
||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||
if (request.context.get(NO_PROGRESS_BAR)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
this.progressBarService.increase();
|
||||
return next.handle(request).pipe(
|
||||
finalize(() => {
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
/** Angular Imports */
|
||||
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ShellComponent } from '../shell/shell.component';
|
||||
|
||||
/**
|
||||
* A route strategy allowing for explicit route reuse.
|
||||
@@ -32,22 +31,7 @@ export class RouteReusableStrategy extends RouteReuseStrategy {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every feature module registers its own copy of the shell route (Route.withShell), so two pages in different modules
|
||||
* sit under two different shell routes. Treated as different, a click from one module into another destroyed the
|
||||
* shell and built a new one: the menu bar vanished until it had loaded again, and the shell's own calls (menu access,
|
||||
* notifications, business date, the approval stream) went out again on every such click. The shell is the same
|
||||
* component whichever module it wraps, so it is kept: only the page inside it changes, until a hard refresh.
|
||||
*/
|
||||
public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
|
||||
return (
|
||||
future.routeConfig === curr.routeConfig ||
|
||||
future.data.reuse ||
|
||||
(RouteReusableStrategy.isShell(future) && RouteReusableStrategy.isShell(curr))
|
||||
);
|
||||
}
|
||||
|
||||
private static isShell(route: ActivatedRouteSnapshot): boolean {
|
||||
return route.routeConfig?.component === ShellComponent;
|
||||
return future.routeConfig === curr.routeConfig || future.data.reuse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +407,15 @@
|
||||
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</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'">
|
||||
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
@@ -465,11 +474,6 @@
|
||||
*mifosxHasPermission="'MENU_TASKS'"
|
||||
>
|
||||
<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 class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
|
||||
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
|
||||
@@ -513,6 +517,16 @@
|
||||
><span class="ni-short">Loans</span>
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</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
|
||||
class="ni"
|
||||
tabindex="0"
|
||||
@@ -1232,6 +1246,9 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/current']">
|
||||
<fa-icon icon="building" class="menu-item-icon"></fa-icon> Current
|
||||
</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']">
|
||||
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Fixed Deposit
|
||||
</button>
|
||||
|
||||
@@ -1082,18 +1082,3 @@ $gray-700: #334155;
|
||||
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,7 +17,6 @@ import { TenantMenuAccessService } from '../../directives/has-permission/tenant-
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { ApprovalCountsService } from '../../tasks/approval-counts.service';
|
||||
import { map, filter } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
@@ -69,11 +68,6 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
private authService = inject(AuthenticationService);
|
||||
private clerkUiStateService = inject(ClerkUiStateService);
|
||||
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. */
|
||||
isHandset$: Observable<boolean> = this.breakpointObserver
|
||||
@@ -81,9 +75,8 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
.pipe(map((result) => result.matches));
|
||||
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
|
||||
sidenavCollapsed = true;
|
||||
/** Progress bar mode. It starts from what is running right now: the template shows the bar for anything that is not
|
||||
* 'none', so an empty start left it running until some request happened to finish. */
|
||||
progressBarMode: string = this.progressBarService.getRequestsRunning() > 0 ? 'indeterminate' : 'none';
|
||||
/** Progress bar mode. */
|
||||
progressBarMode: string;
|
||||
/** Subscription to progress bar. */
|
||||
progressBar$: Subscription;
|
||||
/** Hides nav/sidebar for full-screen cashier routes. */
|
||||
@@ -151,13 +144,6 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
* Subscribes to progress bar to update its mode.
|
||||
*/
|
||||
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.progressBarMode = mode;
|
||||
this.cdr.detectChanges();
|
||||
@@ -401,8 +387,6 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
* Unsubscribes from progress bar.
|
||||
*/
|
||||
ngOnDestroy() {
|
||||
this.approvalTotalSub?.unsubscribe();
|
||||
this.approvalCounts.stop();
|
||||
if (this.progressBar$) {
|
||||
this.progressBar$.unsubscribe();
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
<fa-icon icon="hand-holding-usd" class="ai-col-h-ic"></fa-icon>
|
||||
<span>Lending & Risk</span>
|
||||
</div>
|
||||
<button mat-menu-item class="ai-card" tabindex="0">
|
||||
<button mat-menu-item class="ai-card" routerLink="/ai/loan-eligibility" tabindex="0">
|
||||
<span class="ai-card-ic"><fa-icon icon="hand-holding-usd"></fa-icon></span>
|
||||
<span class="ai-card-body">
|
||||
<span class="ai-card-t">Loan Eligibility</span>
|
||||
@@ -239,6 +239,27 @@
|
||||
</span>
|
||||
</button>
|
||||
</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 class="ai-menu-footer">
|
||||
|
||||
@@ -373,7 +373,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
||||
// ===== AI Mega Menu Panel =====
|
||||
::ng-deep .ai-menu {
|
||||
&.mat-mdc-menu-panel {
|
||||
min-width: 880px !important;
|
||||
min-width: 1080px !important;
|
||||
max-width: 96vw !important;
|
||||
max-height: 80vh;
|
||||
border-radius: 14px !important;
|
||||
@@ -520,7 +520,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
||||
// ===== Mega Layout — 4 colour-coded columns =====
|
||||
.ai-menu-cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0;
|
||||
padding: 14px 14px 6px;
|
||||
background: #f8fafc;
|
||||
@@ -567,6 +567,12 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
||||
--c-hover: #be185d;
|
||||
}
|
||||
|
||||
.ai-col--blue {
|
||||
--c-color: #0369a1;
|
||||
--c-bg: #e0f2fe;
|
||||
--c-hover: #0369a1;
|
||||
}
|
||||
|
||||
.ai-col-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -702,7 +708,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
||||
}
|
||||
|
||||
// Responsive collapse
|
||||
@media (width <= 1100px) {
|
||||
@media (width <= 1300px) {
|
||||
::ng-deep .ai-menu.mat-mdc-menu-panel {
|
||||
min-width: 680px !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
<!--
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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' : '';
|
||||
}
|
||||
}
|
||||
@@ -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="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>
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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 {}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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,6 +13,18 @@
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<h3>Account Details</h3>
|
||||
</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">
|
||||
<mat-form-field appearance="outline" class="sad-field">
|
||||
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
||||
|
||||
+13
@@ -1735,3 +1735,16 @@ $ni-bg: #f9fafb;
|
||||
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,6 +31,8 @@ import { AddRelatedClientDialogComponent } from './add-related-client-dialog/add
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ProductsService } from 'app/products/products.service';
|
||||
|
||||
/**
|
||||
* Current Account Details Step
|
||||
@@ -58,6 +60,13 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
private alertService = inject(AlertService);
|
||||
private dateUtils = inject(Dates);
|
||||
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() hideButtons = false;
|
||||
@@ -216,8 +225,13 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
this.isCcRequest = this.route.snapshot.queryParamMap.get('cc') === '1';
|
||||
|
||||
if (this.currentAccountTemplate) {
|
||||
this.productData = this.currentAccountTemplate.productOptions;
|
||||
if (this.isCcRequest && !this.currentAccountTemplate.accountNo) {
|
||||
this.limitProductsToCc();
|
||||
}
|
||||
const existingProductId =
|
||||
this.currentAccountTemplate.currentAccountProductId ?? this.currentAccountTemplate.productId;
|
||||
// Edit mode: account response always has accountNo; pure template has previewAccountNo only
|
||||
@@ -255,6 +269,24 @@ 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() {
|
||||
this.currentAccountDetailsForm = this.formBuilder.group({
|
||||
productId: [
|
||||
|
||||
+4
-57
@@ -331,14 +331,12 @@
|
||||
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
|
||||
</div>
|
||||
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
|
||||
<span class="fdrc-maturity-row__label">{{
|
||||
isSpecial ? 'Total Interest (paid out monthly)' : 'Interest Earned'
|
||||
}}</span>
|
||||
<span class="fdrc-maturity-row__label">Interest Earned</span>
|
||||
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
|
||||
<div class="fdrc-maturity-row">
|
||||
<span class="fdrc-maturity-row__label">Monthly Payout (approx., by days in the month)</span>
|
||||
<span class="fdrc-maturity-row__label">Monthly Payout</span>
|
||||
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
|
||||
₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month
|
||||
</span>
|
||||
@@ -353,64 +351,13 @@
|
||||
}
|
||||
</span>
|
||||
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big">
|
||||
₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
|
||||
₹ {{ (isSpecial ? principalAmount : matchResult.maturityAmount) | number: '1.2-2' }}
|
||||
</span>
|
||||
</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>
|
||||
}
|
||||
}
|
||||
@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) {
|
||||
@if (!matchResult.maturityAmount) {
|
||||
<div class="fdrc-payout-hint">
|
||||
<i class="fas fa-lightbulb"></i>
|
||||
Enter a deposit amount above to see the maturity calculation.
|
||||
|
||||
-69
@@ -1229,72 +1229,3 @@ $red-light: #fee2e2;
|
||||
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;
|
||||
}
|
||||
|
||||
+79
-140
@@ -36,39 +36,12 @@ import { FormsModule } from '@angular/forms';
|
||||
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
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 { SettingsService } from 'app/settings/settings.service';
|
||||
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
|
||||
|
||||
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 {
|
||||
slab: any;
|
||||
slabIndex: number;
|
||||
@@ -114,7 +87,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@Input() fixedDepositsAccountTemplate: any;
|
||||
@Input() fixedDepositsAccountProductTemplate: any;
|
||||
@@ -191,10 +163,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// ── FD Type labels ──────────────────────────────────────────────────────
|
||||
@@ -302,8 +270,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
calculateRate() {
|
||||
this.validationWarning = null;
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
|
||||
if (!this.tenureValue || this.tenureValue <= 0) {
|
||||
this.validationWarning = 'Please enter a valid tenure value greater than 0.';
|
||||
@@ -368,6 +334,30 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
||||
|
||||
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 = () => {
|
||||
if (frequencyId !== null) {
|
||||
this.tenureSelected.emit({
|
||||
@@ -385,7 +375,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
// Get submittedOnDate — Input takes priority over template data
|
||||
let submittedOnDateStr: string;
|
||||
if (this.submittedOnDate) {
|
||||
submittedOnDateStr = localIsoDate(this.submittedOnDate);
|
||||
submittedOnDateStr = this.submittedOnDate.toISOString().split('T')[0];
|
||||
} else {
|
||||
const submittedOnDateRaw =
|
||||
this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ??
|
||||
@@ -394,18 +384,63 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
const d = Array.isArray(submittedOnDateRaw)
|
||||
? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2])
|
||||
: new Date(submittedOnDateRaw);
|
||||
submittedOnDateStr = !isNaN(d.getTime()) ? localIsoDate(d) : localIsoDate(new Date());
|
||||
submittedOnDateStr = !isNaN(d.getTime())
|
||||
? d.toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0];
|
||||
} else {
|
||||
submittedOnDateStr = localIsoDate(new Date());
|
||||
submittedOnDateStr = new Date().toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
// The whole quote - rate, maturity date, maturity amount and, for FD Special, the month-by-month payout
|
||||
// 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
|
||||
// interest engine. Nothing is worked out here: on an error the quote is simply not shown, never estimated.
|
||||
if (frequencyId !== null) {
|
||||
this.loadAccountPreview(result, rate, submittedOnDateStr, frequencyId, emitResult);
|
||||
}
|
||||
const daysInYearVal =
|
||||
this.fixedDepositsAccountProductTemplate?.interestCalculationDaysInYearType?.id === 360 ? 360 : 365;
|
||||
|
||||
// Always use backend API for accurate calculation (including daily compounding with 0)
|
||||
this.fixedDepositsService
|
||||
.calculateFixedDepositInterest({
|
||||
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 {
|
||||
this.matchResult = result;
|
||||
if (frequencyId !== null) {
|
||||
@@ -422,96 +457,6 @@ 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 {
|
||||
const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? [];
|
||||
const unitLower = unit.toLowerCase();
|
||||
@@ -526,16 +471,12 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
this.tenureValue = null;
|
||||
this.principalAmount = null;
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
this.validationWarning = null;
|
||||
this.tenurePeriodUnit = this.allowedPeriodUnits[0];
|
||||
}
|
||||
|
||||
onPeriodUnitChange() {
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
this.validationWarning = null;
|
||||
}
|
||||
|
||||
@@ -583,8 +524,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
this.calculateRate();
|
||||
} else {
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-7
@@ -326,7 +326,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Inactive</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToInactive ?? null) !== null) {
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToInactive != null) {
|
||||
After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -340,7 +340,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Dormant</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToDormancy ?? null) !== null) {
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToDormancy != null) {
|
||||
{{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -354,7 +354,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Escheat</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToEscheat ?? null) !== null) {
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToEscheat != null) {
|
||||
{{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -458,15 +458,13 @@
|
||||
<span class="preview-value">{{ maturitySummary.tenureLabel }}</span>
|
||||
</div>
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{
|
||||
isSpecial ? 'Total Interest (paid out monthly)' : 'Estimated Interest Earned'
|
||||
}}</span>
|
||||
<span class="preview-label">Estimated Interest Earned</span>
|
||||
<span class="preview-value" style="color: var(--theme-success, #28a745); font-weight: 500">
|
||||
+ {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="preview-field" style="grid-column: span 2">
|
||||
<span class="preview-label">{{ isSpecial ? 'Principal at Maturity' : 'Projected Maturity Amount' }}</span>
|
||||
<span class="preview-label">Projected Maturity Amount</span>
|
||||
<span class="preview-value preview-mono" style="font-weight: 600; font-size: 1.25rem">
|
||||
{{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }}
|
||||
</span>
|
||||
|
||||
-5
@@ -145,11 +145,6 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
|
||||
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 {
|
||||
return this.fdTypeLabels[fdType] || fdType;
|
||||
}
|
||||
|
||||
+15
-37
@@ -11,25 +11,15 @@
|
||||
<span class="isd-header__icon"><fa-icon icon="calendar-alt"></fa-icon></span>
|
||||
<span class="isd-header__text">
|
||||
<span class="isd-header__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</span>
|
||||
<span class="isd-header__sub">
|
||||
@if (isPreview) {
|
||||
{{ schedule.length }} monthly payouts
|
||||
} @else {
|
||||
{{ paidCount }} of {{ schedule.length }} periods paid so far
|
||||
}
|
||||
</span>
|
||||
<span class="isd-header__sub">{{ paidCount }} of {{ schedule.length }} periods paid so far</span>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div mat-dialog-content class="isd-content">
|
||||
<p class="isd-note">
|
||||
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
||||
@if (data.note) {
|
||||
{{ 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.
|
||||
}
|
||||
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>
|
||||
|
||||
<div class="isd-scroll">
|
||||
@@ -38,13 +28,8 @@
|
||||
<tr>
|
||||
<th>{{ 'labels.inputs.From 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>
|
||||
@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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -52,35 +37,28 @@
|
||||
<tr [class.isd-table__row--paid]="period.paid">
|
||||
<td>{{ period.fromDate | dateFormat }}</td>
|
||||
<td>{{ period.toDate | dateFormat }}</td>
|
||||
@if (isPreview) {
|
||||
<td class="isd-table__amount-col">{{ period.days }}</td>
|
||||
}
|
||||
<td class="isd-table__amount-col">
|
||||
{{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||
</td>
|
||||
@if (!isPreview) {
|
||||
<td class="isd-table__status-col">
|
||||
@if (period.paid) {
|
||||
<span class="isd-badge isd-badge--paid">
|
||||
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
|
||||
</span>
|
||||
} @else {
|
||||
<span class="isd-badge isd-badge--pending">Pending</span>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="isd-table__status-col">
|
||||
@if (period.paid) {
|
||||
<span class="isd-badge isd-badge--paid">
|
||||
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
|
||||
</span>
|
||||
} @else {
|
||||
<span class="isd-badge isd-badge--pending">Pending</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td [attr.colspan]="isPreview ? 3 : 2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
|
||||
<td colspan="2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
|
||||
<td class="isd-table__amount-col">
|
||||
{{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||
</td>
|
||||
@if (!isPreview) {
|
||||
<td></td>
|
||||
}
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
+2
-13
@@ -26,9 +26,6 @@ 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
|
||||
* the page. Each row also shows whether that period's interest has actually posted yet, not just what
|
||||
* 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({
|
||||
selector: 'mifosx-interest-schedule-dialog',
|
||||
@@ -49,22 +46,14 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
export class InterestScheduleDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
|
||||
data: {
|
||||
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }>;
|
||||
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>;
|
||||
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);
|
||||
|
||||
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }> {
|
||||
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> {
|
||||
return this.data.schedule ?? [];
|
||||
}
|
||||
|
||||
get isPreview(): boolean {
|
||||
return !!this.data.preview;
|
||||
}
|
||||
|
||||
get currencyCode(): string {
|
||||
return this.data.currencyCode;
|
||||
}
|
||||
|
||||
@@ -154,16 +154,6 @@ export class FixedDepositsService {
|
||||
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
|
||||
* configured: backdated to the original maturity date within the grace window, or from today
|
||||
|
||||
+3
-4
@@ -110,8 +110,8 @@ export class CreateRecurringDepositsAccountComponent {
|
||||
*/
|
||||
setTemplate($event: any) {
|
||||
this.recurringDepositsAccountProductTemplate = $event;
|
||||
this.productsService.getRdBaseInterestRates().subscribe({
|
||||
next: (rates: any) => (this.cbsBaseRates = Array.isArray(rates) ? rates : rates ? [rates] : []),
|
||||
this.productsService.getFdBaseInterestRates().subscribe({
|
||||
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
|
||||
error: () => (this.cbsBaseRates = [])
|
||||
});
|
||||
}
|
||||
@@ -198,8 +198,7 @@ export class CreateRecurringDepositsAccountComponent {
|
||||
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
|
||||
this.alertService.alert({
|
||||
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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -144,8 +144,8 @@ export class EditRecurringDepositAccountComponent {
|
||||
|
||||
setTemplate($event: any) {
|
||||
this.recurringDepositsAccountProductTemplate = $event;
|
||||
this.productsService.getRdBaseInterestRates().subscribe({
|
||||
next: (rates: any) => (this.cbsBaseRates = Array.isArray(rates) ? rates : rates ? [rates] : []),
|
||||
this.productsService.getFdBaseInterestRates().subscribe({
|
||||
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
|
||||
error: () => (this.cbsBaseRates = [])
|
||||
});
|
||||
}
|
||||
|
||||
+2
-6
@@ -127,8 +127,7 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
}>();
|
||||
|
||||
get activeBase(): any {
|
||||
// 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];
|
||||
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,10 +173,7 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
return baseSlabs;
|
||||
}
|
||||
// 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) => {
|
||||
if (slab.seniorCitizenRate != null) {
|
||||
return slab;
|
||||
|
||||
-28
@@ -50,34 +50,6 @@
|
||||
<span class="nx-row__label">{{ 'labels.inputs.Maturity Date' | translate }}</span>
|
||||
<span class="nx-row__value">{{ recurringDepositsAccountData.maturityDate | dateFormat }}</span>
|
||||
</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 (
|
||||
recurringDepositsAccountData.timeline.closedOnDate || recurringDepositsAccountData.summary.totalWitddrawals
|
||||
) {
|
||||
|
||||
-31
@@ -46,21 +46,11 @@ export class GeneralTabComponent {
|
||||
totalLienAmount = 0;
|
||||
/** 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;
|
||||
/** 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() {
|
||||
this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => {
|
||||
this.recurringDepositsAccountData = data.recurringDepositsAccountData;
|
||||
this.loadLiens(this.recurringDepositsAccountData?.id);
|
||||
this.loadInstallmentPosition(this.recurringDepositsAccountData?.id);
|
||||
this.currency = this.recurringDepositsAccountData.currency;
|
||||
this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null;
|
||||
if (this.router.url.includes('clients')) {
|
||||
@@ -73,27 +63,6 @@ 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) {
|
||||
this.liens = [];
|
||||
this.totalLienAmount = 0;
|
||||
|
||||
@@ -39,16 +39,6 @@ export class RecurringDepositsService {
|
||||
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 clientName Client Name
|
||||
|
||||
+6
-12
@@ -719,8 +719,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
input[type='number'] {
|
||||
[type='number'] {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
@@ -760,8 +759,7 @@
|
||||
}
|
||||
|
||||
/* :where() keeps these defaults at zero specificity, so the table's own cell inputs always win. */
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
:where(input[type='text'], input[type='number'], input[type='date'], select, textarea) {
|
||||
:where([type='text'], [type='number'], [type='date'], select, textarea) {
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
padding: 6px 8px;
|
||||
@@ -773,13 +771,11 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
:where(input[type='text'], select) {
|
||||
:where([type='text'], select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
:where(input[type='checkbox']) {
|
||||
:where([type='checkbox']) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@@ -869,8 +865,7 @@
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
input[type='number'] {
|
||||
[type='number'] {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
@@ -1180,8 +1175,7 @@
|
||||
gap: 8px;
|
||||
max-width: 720px;
|
||||
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
input[type='text'] {
|
||||
[type='text'] {
|
||||
flex: 1;
|
||||
padding: 9px 11px;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface ReserveTransferData {
|
||||
export class ReserveTransferDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<ReserveTransferDialogComponent>);
|
||||
private rbi = inject(RbiCircularsService);
|
||||
readonly data: ReserveTransferData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
/** The account money leaves. Fixed — the dialog is opened from its row. */
|
||||
readonly from: TransferAccount;
|
||||
@@ -75,8 +76,6 @@ export class ReserveTransferDialogComponent {
|
||||
submitting = false;
|
||||
error = '';
|
||||
|
||||
readonly data = inject<ReserveTransferData>(MAT_DIALOG_DATA);
|
||||
|
||||
constructor() {
|
||||
this.from = this.data.from;
|
||||
this.destinations = (this.data.accounts ?? []).filter((a) => a.glAccountId !== this.data.from.glAccountId);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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 {}
|
||||
@@ -0,0 +1,482 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
+6
-10
@@ -6,14 +6,10 @@
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
@use '../../../shared/styles/list-layout' as *;
|
||||
import { NgModule } from '@angular/core';
|
||||
import { LoanEligibilityRoutingModule } from './loan-eligibility-routing.module';
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pf-subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: rgb(0 0 0 / 60%);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@NgModule({
|
||||
imports: [LoanEligibilityRoutingModule]
|
||||
})
|
||||
export class LoanEligibilityModule {}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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'));
|
||||
}
|
||||
}
|
||||
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,664 +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 { jsPDF } from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
(pdfjsLib as any).GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
/** One block of the form. The screen works out the rows; this file only draws them. */
|
||||
export type LoanFormSection =
|
||||
| { title: string; type: 'kv'; rows: [string, string][] }
|
||||
| { title: string; type: 'compare'; rows: [string, string, string][]; jointName: string }
|
||||
| { title: string; type: 'table'; head: string[]; rows: string[][]; note?: string; minRows?: number }
|
||||
| { title: string; type: 'text'; text: string };
|
||||
|
||||
export interface LoanApplicationData {
|
||||
bankName: string;
|
||||
branchName: string;
|
||||
applicationNo: string;
|
||||
applicationDate: string;
|
||||
generatedOn: string;
|
||||
amount: string;
|
||||
amountInWords: string;
|
||||
scheme: string;
|
||||
purpose: string;
|
||||
/** The applicant's photograph as a data URL, when the client record has one. */
|
||||
photo: string | null;
|
||||
jointPhoto: boolean;
|
||||
form: LoanFormSection[];
|
||||
declaration: string;
|
||||
/** What the bank fills in. */
|
||||
bankUse: [string, string][];
|
||||
/** After the form: what the sanctioning authority needs to see. */
|
||||
annexure: LoanFormSection[];
|
||||
}
|
||||
|
||||
/** A file that goes into the pack after the form: the customer's eKYC documents, the lien documents and the rest. */
|
||||
export interface LoanApplicationAttachment {
|
||||
group: string;
|
||||
label: string;
|
||||
fileName: string;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
const MARGIN = 12;
|
||||
const NAVY: [number, number, number] = [
|
||||
15,
|
||||
23,
|
||||
42
|
||||
];
|
||||
const BLUE: [number, number, number] = [
|
||||
30,
|
||||
64,
|
||||
175
|
||||
];
|
||||
const SOFT: [number, number, number] = [
|
||||
241,
|
||||
245,
|
||||
249
|
||||
];
|
||||
const LINE: [number, number, number] = [
|
||||
203,
|
||||
213,
|
||||
225
|
||||
];
|
||||
const MUTED: [number, number, number] = [
|
||||
100,
|
||||
116,
|
||||
139
|
||||
];
|
||||
/** A long PDF would make the pack unmanageable: its first pages go in, and the pack says so. */
|
||||
const MAX_PDF_PAGES = 12;
|
||||
|
||||
type Doc = jsPDF & { lastAutoTable?: { finalY: number } };
|
||||
|
||||
const finalY = (doc: Doc): number => doc.lastAutoTable?.finalY ?? 0;
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
/** 125000 -> "One Lakh Twenty Five Thousand" (Indian grouping). */
|
||||
export function amountInWords(value: number): string {
|
||||
const ones = [
|
||||
'',
|
||||
'One',
|
||||
'Two',
|
||||
'Three',
|
||||
'Four',
|
||||
'Five',
|
||||
'Six',
|
||||
'Seven',
|
||||
'Eight',
|
||||
'Nine',
|
||||
'Ten',
|
||||
'Eleven',
|
||||
'Twelve',
|
||||
'Thirteen',
|
||||
'Fourteen',
|
||||
'Fifteen',
|
||||
'Sixteen',
|
||||
'Seventeen',
|
||||
'Eighteen',
|
||||
'Nineteen'
|
||||
];
|
||||
const tens = [
|
||||
'',
|
||||
'',
|
||||
'Twenty',
|
||||
'Thirty',
|
||||
'Forty',
|
||||
'Fifty',
|
||||
'Sixty',
|
||||
'Seventy',
|
||||
'Eighty',
|
||||
'Ninety'
|
||||
];
|
||||
const below100 = (n: number): string =>
|
||||
n < 20 ? ones[n] : tens[Math.floor(n / 10)] + (n % 10 ? ' ' + ones[n % 10] : '');
|
||||
const below1000 = (n: number): string =>
|
||||
(n >= 100 ? ones[Math.floor(n / 100)] + ' Hundred' + (n % 100 ? ' ' : '') : '') +
|
||||
(n % 100 ? below100(n % 100) : '');
|
||||
let n = Math.floor(Math.abs(value));
|
||||
if (n === 0) {
|
||||
return 'Zero';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
const crore = Math.floor(n / 10000000);
|
||||
n %= 10000000;
|
||||
const lakh = Math.floor(n / 100000);
|
||||
n %= 100000;
|
||||
const thousand = Math.floor(n / 1000);
|
||||
n %= 1000;
|
||||
if (crore) parts.push(below1000(crore) + ' Crore');
|
||||
if (lakh) parts.push(below100(lakh) + ' Lakh');
|
||||
if (thousand) parts.push(below100(thousand) + ' Thousand');
|
||||
if (n) parts.push(below1000(n));
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function readAsDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('image'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/** A numbered bar that opens each block of the form. */
|
||||
function sectionBar(doc: Doc, number: number, title: string, y: number): number {
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
doc.setFillColor(...SOFT);
|
||||
doc.roundedRect(MARGIN, y, pageWidth - MARGIN * 2, 7.5, 1.2, 1.2, 'F');
|
||||
doc.setFillColor(...BLUE);
|
||||
doc.roundedRect(MARGIN, y, 7.5, 7.5, 1.2, 1.2, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(9);
|
||||
doc.text(String(number), MARGIN + 3.75, y + 5.2, { align: 'center' });
|
||||
doc.setTextColor(...NAVY);
|
||||
doc.setFontSize(9.5);
|
||||
doc.text(title.toUpperCase(), MARGIN + 11, y + 5.2);
|
||||
return y + 10.5;
|
||||
}
|
||||
|
||||
/** Starts a new page when what comes next would not fit. */
|
||||
function room(doc: Doc, y: number, needed: number): number {
|
||||
if (y + needed > doc.internal.pageSize.getHeight() - 14) {
|
||||
doc.addPage();
|
||||
return 16;
|
||||
}
|
||||
return y;
|
||||
}
|
||||
|
||||
const baseTable = {
|
||||
theme: 'grid' as const,
|
||||
styles: {
|
||||
fontSize: 8.6,
|
||||
cellPadding: { top: 2, bottom: 2, left: 2.4, right: 2.4 },
|
||||
lineColor: LINE,
|
||||
lineWidth: 0.2,
|
||||
textColor: NAVY,
|
||||
valign: 'middle' as const
|
||||
},
|
||||
headStyles: { fillColor: NAVY, textColor: [
|
||||
255,
|
||||
255,
|
||||
255
|
||||
] as [number, number, number], fontStyle: 'bold' as const, halign: 'left' as const },
|
||||
alternateRowStyles: { fillColor: [
|
||||
250,
|
||||
251,
|
||||
253
|
||||
] as [number, number, number] },
|
||||
margin: { left: MARGIN, right: MARGIN }
|
||||
};
|
||||
|
||||
/** About how tall a block will be, so a block that fits on a page is not split across two. */
|
||||
function estimatedHeight(s: LoanFormSection): number {
|
||||
const rowH = 9;
|
||||
switch (s.type) {
|
||||
case 'kv':
|
||||
return 12 + s.rows.length * rowH;
|
||||
case 'compare':
|
||||
return 14 + s.rows.length * rowH + 14;
|
||||
case 'table':
|
||||
return 14 + Math.max(s.rows.length, s.minRows ?? 1, 1) * rowH;
|
||||
default:
|
||||
return 30;
|
||||
}
|
||||
}
|
||||
|
||||
function drawSection(doc: Doc, n: number, s: LoanFormSection, startY: number): number {
|
||||
// a block that fits on one page starts on a new page rather than being cut in two
|
||||
const usable = doc.internal.pageSize.getHeight() - 30;
|
||||
const needed = Math.min(estimatedHeight(s), usable);
|
||||
let y = room(doc, startY, needed);
|
||||
y = sectionBar(doc, n, s.title, y);
|
||||
if (s.type === 'kv') {
|
||||
autoTable(doc, {
|
||||
...baseTable,
|
||||
startY: y,
|
||||
body: s.rows.map(
|
||||
([
|
||||
k,
|
||||
v
|
||||
]) => [
|
||||
k,
|
||||
v
|
||||
]
|
||||
),
|
||||
columnStyles: { 0: { cellWidth: 62, fontStyle: 'bold', fillColor: SOFT, textColor: [
|
||||
51,
|
||||
65,
|
||||
85
|
||||
] } },
|
||||
bodyStyles: { minCellHeight: 7 }
|
||||
});
|
||||
} else if (s.type === 'compare') {
|
||||
autoTable(doc, {
|
||||
...baseTable,
|
||||
startY: y,
|
||||
head: [[
|
||||
'Particulars',
|
||||
'Applicant',
|
||||
s.jointName || 'Joint applicant'
|
||||
]],
|
||||
body: s.rows.map((r) => [
|
||||
r[0],
|
||||
r[1],
|
||||
r[2]
|
||||
]),
|
||||
columnStyles: {
|
||||
0: { cellWidth: 46, fontStyle: 'bold', fillColor: SOFT, textColor: [
|
||||
51,
|
||||
65,
|
||||
85
|
||||
] },
|
||||
1: { cellWidth: 70 }
|
||||
},
|
||||
bodyStyles: { minCellHeight: 7 }
|
||||
});
|
||||
} else if (s.type === 'table') {
|
||||
const rows = s.rows.length ? s.rows : [];
|
||||
const padded = [...rows];
|
||||
while (padded.length < (s.minRows ?? 0)) {
|
||||
padded.push(s.head.map(() => ''));
|
||||
}
|
||||
if (!padded.length) {
|
||||
padded.push([
|
||||
s.note ?? 'Nil',
|
||||
...s.head.slice(1).map(() => '')
|
||||
]);
|
||||
}
|
||||
autoTable(doc, { ...baseTable, startY: y, head: [s.head], body: padded, bodyStyles: { minCellHeight: 7 } });
|
||||
} else {
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(8.6);
|
||||
doc.setTextColor(...NAVY);
|
||||
const lines = doc.splitTextToSize(s.text, doc.internal.pageSize.getWidth() - MARGIN * 2);
|
||||
doc.text(lines, MARGIN, y + 3);
|
||||
return y + 3 + lines.length * 4 + 4;
|
||||
}
|
||||
return finalY(doc) + 6;
|
||||
}
|
||||
|
||||
// ───────────────────────────── attachments ─────────────────────────────
|
||||
|
||||
function attachmentHeader(doc: Doc, index: number, total: number, a: LoanApplicationAttachment, note: string): number {
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
doc.setFillColor(...SOFT);
|
||||
doc.rect(0, 0, pageWidth, 16, 'F');
|
||||
doc.setFillColor(...BLUE);
|
||||
doc.rect(0, 0, 3, 16, 'F');
|
||||
doc.setTextColor(...NAVY);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Attachment ${index} of ${total} — ${a.label}`, MARGIN, 7);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.text(`${a.group} · ${a.fileName}${note ? ' · ' + note : ''}`, MARGIN, 12.5);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
return 20;
|
||||
}
|
||||
|
||||
function placePicture(doc: Doc, dataUrl: string, format: 'JPEG' | 'PNG', w: number, h: number, top: number): void {
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const maxW = pageWidth - MARGIN * 2;
|
||||
const maxH = pageHeight - top - 12;
|
||||
const ratio = Math.min(maxW / w, maxH / h);
|
||||
const width = w * ratio;
|
||||
const height = h * ratio;
|
||||
doc.addImage(dataUrl, format, (pageWidth - width) / 2, top, width, height);
|
||||
}
|
||||
|
||||
async function appendImage(doc: Doc, index: number, total: number, a: LoanApplicationAttachment): Promise<void> {
|
||||
const dataUrl = await readAsDataUrl(a.blob);
|
||||
const img = await loadImage(dataUrl);
|
||||
doc.addPage();
|
||||
const top = attachmentHeader(doc, index, total, a, '');
|
||||
placePicture(
|
||||
doc,
|
||||
dataUrl,
|
||||
/png/i.test(a.blob.type) ? 'PNG' : 'JPEG',
|
||||
img.naturalWidth || 1,
|
||||
img.naturalHeight || 1,
|
||||
top
|
||||
);
|
||||
}
|
||||
|
||||
async function appendPdf(doc: Doc, index: number, total: number, a: LoanApplicationAttachment): Promise<void> {
|
||||
const data = new Uint8Array(await a.blob.arrayBuffer());
|
||||
const pdf = await (pdfjsLib as any).getDocument({ data }).promise;
|
||||
const pages = Math.min(pdf.numPages, MAX_PDF_PAGES);
|
||||
for (let p = 1; p <= pages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const viewport = page.getViewport({ scale: 1.5 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.ceil(viewport.width);
|
||||
canvas.height = Math.ceil(viewport.height);
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('canvas');
|
||||
}
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
doc.addPage();
|
||||
const note =
|
||||
pdf.numPages > 1
|
||||
? `page ${p} of ${pdf.numPages}${pdf.numPages > pages && p === pages ? ' (first ' + pages + ' pages only)' : ''}`
|
||||
: '';
|
||||
const top = attachmentHeader(doc, index, total, a, note);
|
||||
placePicture(doc, canvas.toDataURL('image/jpeg', 0.82), 'JPEG', canvas.width, canvas.height, top);
|
||||
}
|
||||
}
|
||||
|
||||
async function appendAttachment(doc: Doc, index: number, total: number, a: LoanApplicationAttachment): Promise<void> {
|
||||
try {
|
||||
if (/pdf/i.test(a.blob.type) || /\.pdf$/i.test(a.fileName)) {
|
||||
await appendPdf(doc, index, total, a);
|
||||
} else if (/^image\//i.test(a.blob.type)) {
|
||||
await appendImage(doc, index, total, a);
|
||||
} else {
|
||||
throw new Error('type');
|
||||
}
|
||||
} catch {
|
||||
doc.addPage();
|
||||
const top = attachmentHeader(doc, index, total, a, '');
|
||||
doc.setFontSize(10);
|
||||
doc.text(
|
||||
'This file could not be placed in the pack (' +
|
||||
(a.blob.type || 'unknown type') +
|
||||
'). Open it from the application.',
|
||||
MARGIN,
|
||||
top + 6
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── the form ─────────────────────────────
|
||||
|
||||
function drawHeader(doc: Doc, d: LoanApplicationData): number {
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
// top band
|
||||
doc.setFillColor(...NAVY);
|
||||
doc.rect(0, 0, pageWidth, 34, 'F');
|
||||
doc.setFillColor(...BLUE);
|
||||
doc.rect(0, 34, pageWidth, 1.6, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(17);
|
||||
doc.text(d.bankName.toUpperCase(), MARGIN, 14);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(186, 200, 230);
|
||||
doc.text(d.branchName, MARGIN, 20);
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(14);
|
||||
doc.text('LOAN APPLICATION FORM', MARGIN, 29);
|
||||
// application number card
|
||||
const cardW = 56;
|
||||
doc.setFillColor(255, 255, 255);
|
||||
doc.roundedRect(pageWidth - MARGIN - cardW, 7, cardW, 20, 2, 2, 'F');
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(7);
|
||||
doc.text('APPLICATION NO.', pageWidth - MARGIN - cardW + 4, 13);
|
||||
doc.setTextColor(...NAVY);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(11);
|
||||
doc.text(d.applicationNo, pageWidth - MARGIN - cardW + 4, 19);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(7.5);
|
||||
doc.text('Date: ' + d.applicationDate, pageWidth - MARGIN - cardW + 4, 24.5);
|
||||
|
||||
// addressee and photographs
|
||||
let y = 43;
|
||||
doc.setTextColor(...NAVY);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9);
|
||||
doc.text('PART I — Basic information common to all schemes', MARGIN, y);
|
||||
y += 8;
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(9.5);
|
||||
doc.text('To,', MARGIN, y);
|
||||
doc.text('THE BRANCH MANAGER', MARGIN, y + 5.5);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(d.bankName + ' — ' + d.branchName, MARGIN, y + 11);
|
||||
|
||||
const boxW = 27;
|
||||
const boxH = 33;
|
||||
const boxTop = 38;
|
||||
const rightX = pageWidth - MARGIN - boxW;
|
||||
const leftX = rightX - boxW - 5;
|
||||
const drawBox = (x: number, label: string, image: string | null) => {
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.roundedRect(x, boxTop, boxW, boxH, 1.5, 1.5);
|
||||
if (image) {
|
||||
try {
|
||||
doc.addImage(image, 'JPEG', x + 0.8, boxTop + 0.8, boxW - 1.6, boxH - 1.6);
|
||||
return;
|
||||
} catch {
|
||||
// an image that cannot be drawn leaves the box empty
|
||||
}
|
||||
}
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.setFontSize(6.8);
|
||||
doc.text('PHOTOGRAPH', x + boxW / 2, boxTop + 13, { align: 'center' });
|
||||
doc.text('OF', x + boxW / 2, boxTop + 17, { align: 'center' });
|
||||
doc.text(label, x + boxW / 2, boxTop + 21, { align: 'center' });
|
||||
};
|
||||
drawBox(leftX, 'APPLICANT', d.photo);
|
||||
if (d.jointPhoto) {
|
||||
drawBox(rightX, 'JOINT-APPLICANT', null);
|
||||
} else {
|
||||
drawBox(rightX, 'JOINT-APPLICANT', null);
|
||||
}
|
||||
|
||||
// the request, in a panel
|
||||
y = boxTop + boxH + 6;
|
||||
const panelW = pageWidth - MARGIN * 2;
|
||||
const text =
|
||||
`I/We request for a loan of Rs. ${d.amount} (Rupees ${d.amountInWords} only) under ${d.scheme} Scheme` +
|
||||
(d.purpose ? ` for the purpose of ${d.purpose}.` : '.') +
|
||||
' I/We furnish our particulars as below.';
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(9.2);
|
||||
const lines = doc.splitTextToSize(text, panelW - 10);
|
||||
const panelH = lines.length * 4.6 + 7;
|
||||
doc.setFillColor(239, 246, 255);
|
||||
doc.roundedRect(MARGIN, y, panelW, panelH, 1.8, 1.8, 'F');
|
||||
doc.setFillColor(...BLUE);
|
||||
doc.rect(MARGIN, y, 1.6, panelH, 'F');
|
||||
doc.setTextColor(...NAVY);
|
||||
doc.text(lines, MARGIN + 5, y + 6);
|
||||
return y + panelH + 6;
|
||||
}
|
||||
|
||||
function drawSignatures(doc: Doc, y: number, jointName: string): number {
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
y = room(doc, y, 36);
|
||||
const colW = (pageWidth - MARGIN * 2 - 8) / 2;
|
||||
const boxes: [string, string][] = [
|
||||
[
|
||||
'Signature of the Applicant',
|
||||
''
|
||||
],
|
||||
[
|
||||
'Signature of the Joint-Applicant',
|
||||
jointName
|
||||
]
|
||||
];
|
||||
boxes.forEach(
|
||||
([
|
||||
label,
|
||||
name
|
||||
], i) => {
|
||||
const x = MARGIN + i * (colW + 8);
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.setLineWidth(0.3);
|
||||
doc.roundedRect(x, y, colW, 24, 1.5, 1.5);
|
||||
doc.setDrawColor(...MUTED);
|
||||
doc.line(x + 6, y + 17, x + colW - 6, y + 17);
|
||||
doc.setTextColor(...MUTED);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(7.5);
|
||||
doc.text(label, x + colW / 2, y + 21.5, { align: 'center' });
|
||||
if (name) {
|
||||
doc.setTextColor(...NAVY);
|
||||
doc.setFontSize(8);
|
||||
doc.text(name, x + colW / 2, y + 13, { align: 'center' });
|
||||
}
|
||||
}
|
||||
);
|
||||
doc.setTextColor(...NAVY);
|
||||
doc.setFontSize(8.6);
|
||||
doc.text('Place: ______________________', MARGIN, y + 30);
|
||||
doc.text('Date: ______________________', MARGIN + colW + 8, y + 30);
|
||||
return y + 36;
|
||||
}
|
||||
|
||||
function drawBankUse(doc: Doc, y: number, rows: [string, string][]): number {
|
||||
y = room(doc, y, 20 + rows.length * 7);
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
doc.setFillColor(...NAVY);
|
||||
doc.roundedRect(MARGIN, y, pageWidth - MARGIN * 2, 7, 1.2, 1.2, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(8.8);
|
||||
doc.text('FOR BANK USE ONLY', MARGIN + 4, y + 4.8);
|
||||
autoTable(doc, {
|
||||
...baseTable,
|
||||
startY: y + 8,
|
||||
body: rows,
|
||||
columnStyles: { 0: { cellWidth: 62, fontStyle: 'bold', fillColor: SOFT, textColor: [
|
||||
51,
|
||||
65,
|
||||
85
|
||||
] } },
|
||||
bodyStyles: { minCellHeight: 7 }
|
||||
});
|
||||
return finalY(doc) + 6;
|
||||
}
|
||||
|
||||
/** The loan application form, then the annexure for the sanctioning authority, then every document attached. */
|
||||
export async function buildLoanApplication(
|
||||
d: LoanApplicationData,
|
||||
attachments: LoanApplicationAttachment[]
|
||||
): Promise<jsPDF> {
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }) as Doc;
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
|
||||
let y = drawHeader(doc, d);
|
||||
let n = 1;
|
||||
for (const section of d.form) {
|
||||
y = drawSection(doc, n++, section, y);
|
||||
}
|
||||
|
||||
// declaration
|
||||
y = room(doc, y, 60);
|
||||
y = sectionBar(doc, n++, 'Declaration', y);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setFontSize(7.8);
|
||||
doc.setTextColor(51, 65, 85);
|
||||
const declaration = doc.splitTextToSize(d.declaration, pageWidth - MARGIN * 2);
|
||||
doc.text(declaration, MARGIN, y + 3);
|
||||
y += 3 + declaration.length * 3.6 + 6;
|
||||
|
||||
const jointName = (d.form.find((s) => s.type === 'compare') as { jointName: string } | undefined)?.jointName ?? '';
|
||||
y = drawSignatures(doc, y, jointName);
|
||||
y = drawBankUse(doc, y, d.bankUse);
|
||||
|
||||
// annexure
|
||||
if (d.annexure.length || attachments.length) {
|
||||
doc.addPage();
|
||||
y = 16;
|
||||
doc.setFillColor(...NAVY);
|
||||
doc.rect(0, 0, pageWidth, 12, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setFontSize(10);
|
||||
doc.text('ANNEXURE — For the sanctioning authority', MARGIN, 8);
|
||||
y = 20;
|
||||
let a = 1;
|
||||
for (const section of d.annexure) {
|
||||
y = drawSection(doc, a++, section, y);
|
||||
}
|
||||
y = room(doc, y, 30);
|
||||
y = sectionBar(doc, a++, `Documents attached (${attachments.length})`, y);
|
||||
autoTable(doc, {
|
||||
...baseTable,
|
||||
startY: y,
|
||||
head: [[
|
||||
'#',
|
||||
'Group',
|
||||
'Document',
|
||||
'File'
|
||||
]],
|
||||
body: attachments.length
|
||||
? attachments.map((x, i) => [
|
||||
String(i + 1),
|
||||
x.group,
|
||||
x.label,
|
||||
x.fileName
|
||||
])
|
||||
: [[
|
||||
'',
|
||||
'No document is on file for this application.',
|
||||
'',
|
||||
''
|
||||
]],
|
||||
columnStyles: { 0: { cellWidth: 8 } },
|
||||
styles: { ...baseTable.styles, fontSize: 8 }
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
await appendAttachment(doc, i + 1, attachments.length, attachments[i]);
|
||||
}
|
||||
|
||||
// footer on every page
|
||||
const pages = doc.getNumberOfPages();
|
||||
for (let p = 1; p <= pages; p++) {
|
||||
doc.setPage(p);
|
||||
doc.setDrawColor(...LINE);
|
||||
doc.setLineWidth(0.2);
|
||||
doc.line(
|
||||
MARGIN,
|
||||
doc.internal.pageSize.getHeight() - 10,
|
||||
pageWidth - MARGIN,
|
||||
doc.internal.pageSize.getHeight() - 10
|
||||
);
|
||||
doc.setFontSize(7);
|
||||
doc.setTextColor(150, 150, 150);
|
||||
doc.text(
|
||||
`Loan Application ${d.applicationNo} · ${d.bankName} · generated ${d.generatedOn}`,
|
||||
MARGIN,
|
||||
doc.internal.pageSize.getHeight() - 6
|
||||
);
|
||||
doc.text(`Page ${p} of ${pages}`, pageWidth - MARGIN, doc.internal.pageSize.getHeight() - 6, { align: 'right' });
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
@@ -50,8 +50,7 @@ export async function createRealLoanAccount(
|
||||
|
||||
const locale = settings.language.code;
|
||||
const dateFormat = settings.dateFormat;
|
||||
// Fineract rejects a submitted-on date later than its business date, so use that (the date shown in the header), not the PC clock.
|
||||
const today = dateUtils.formatDate(settings.businessDate, dateFormat);
|
||||
const today = dateUtils.formatDate(new Date(), dateFormat);
|
||||
|
||||
const payload = {
|
||||
clientId: app.clientId,
|
||||
@@ -68,8 +67,6 @@ export async function createRealLoanAccount(
|
||||
interestCalculationPeriodType: template.interestCalculationPeriodType?.id,
|
||||
amortizationType: template.amortizationType?.id,
|
||||
transactionProcessingStrategyCode: template.transactionProcessingStrategyCode,
|
||||
// the application number is the loan's external id, so the application can find (and show) this account
|
||||
externalId: app.applicationNo,
|
||||
submittedOnDate: today,
|
||||
expectedDisbursementDate: today,
|
||||
dateFormat,
|
||||
@@ -87,7 +84,7 @@ export async function createRealLoanAccount(
|
||||
})
|
||||
);
|
||||
|
||||
const disbursed: any = await firstValueFrom(
|
||||
await firstValueFrom(
|
||||
loansService.loanActionButtons(loanId, 'disburse', {
|
||||
actualDisbursementDate: today,
|
||||
transactionAmount: app.requestedAmount,
|
||||
@@ -95,16 +92,6 @@ export async function createRealLoanAccount(
|
||||
locale
|
||||
})
|
||||
);
|
||||
if (disbursed?.rollbackTransaction) {
|
||||
// Disbursement needs a checker (maker-checker): nothing is disbursed until it is approved, so the application must not say so.
|
||||
throw new Error(
|
||||
'The disbursement of loan ' +
|
||||
loanId +
|
||||
' was sent for approval (request ' +
|
||||
disbursed.commandId +
|
||||
'). It happens when it is approved in the Approvals inbox.'
|
||||
);
|
||||
}
|
||||
|
||||
const account: any = await firstValueFrom(loansService.getLoanAccountResource(String(loanId), ''));
|
||||
return {
|
||||
@@ -112,55 +99,3 @@ export async function createRealLoanAccount(
|
||||
loanAccountNo: account?.accountNo || String(loanId)
|
||||
};
|
||||
}
|
||||
|
||||
export interface CoreLienTarget {
|
||||
/** The Fineract deposit account (fixed or recurring) the loan is secured on. */
|
||||
savingsId: number;
|
||||
accountNo: string;
|
||||
/** How much of the loan this deposit secures. */
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a real lien on the pledged FD / RD accounts once the loan account exists.
|
||||
*
|
||||
* Fineract has no separate lien call; its own way to bind a deposit to a loan is a guarantor funded from that deposit:
|
||||
* the amount is held on the account (it cannot be withdrawn or closed), an RD counts it as a lien, and it is released
|
||||
* in step with the loan's repayments and in full when the loan is repaid. The customer guarantees their own loan with
|
||||
* their own deposit, which Fineract allows when the guarantor is funded from a savings account.
|
||||
*
|
||||
* Returns the accounts it could not lien, with the reason, so the screen can say so rather than leaving the
|
||||
* application showing a lien that does not exist.
|
||||
*/
|
||||
export async function placeCoreLien(
|
||||
loansService: LoansService,
|
||||
dateUtils: Dates,
|
||||
settings: SettingsService,
|
||||
loanId: number,
|
||||
clientId: number,
|
||||
targets: CoreLienTarget[]
|
||||
): Promise<{ placed: CoreLienTarget[]; failed: { target: CoreLienTarget; reason: string }[] }> {
|
||||
const placed: CoreLienTarget[] = [];
|
||||
const failed: { target: CoreLienTarget; reason: string }[] = [];
|
||||
for (const target of targets) {
|
||||
try {
|
||||
await firstValueFrom(
|
||||
loansService.createNewGuarantor(String(loanId), {
|
||||
guarantorTypeId: 1,
|
||||
entityId: clientId,
|
||||
savingsId: target.savingsId,
|
||||
amount: target.amount,
|
||||
locale: settings.language.code,
|
||||
dateFormat: settings.dateFormat
|
||||
})
|
||||
);
|
||||
placed.push(target);
|
||||
} catch (err: any) {
|
||||
failed.push({
|
||||
target,
|
||||
reason: err?.error?.errors?.[0]?.defaultUserMessage || err?.message || 'Could not place the lien.'
|
||||
});
|
||||
}
|
||||
}
|
||||
return { placed, failed };
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ export { BRANCHES, BranchMaster };
|
||||
export type OriginationStage =
|
||||
| 'APPLICATION'
|
||||
| 'KYC_DOCUMENTS'
|
||||
| 'DEPOSIT_SELECTION'
|
||||
| 'CIC_CHECK'
|
||||
| 'FIELD_INVESTIGATION'
|
||||
| 'PERSONAL_DISCUSSION'
|
||||
@@ -44,7 +43,6 @@ export type OriginationStage =
|
||||
export const STAGE_ORDER: OriginationStage[] = [
|
||||
'APPLICATION',
|
||||
'KYC_DOCUMENTS',
|
||||
'DEPOSIT_SELECTION',
|
||||
'CIC_CHECK',
|
||||
'FIELD_INVESTIGATION',
|
||||
'PERSONAL_DISCUSSION',
|
||||
@@ -60,7 +58,6 @@ export const STAGE_ORDER: OriginationStage[] = [
|
||||
export const STAGE_LABELS: Record<OriginationStage, string> = {
|
||||
APPLICATION: 'Application Received',
|
||||
KYC_DOCUMENTS: 'KYC & Document Collection',
|
||||
DEPOSIT_SELECTION: 'Deposit Selection & Eligibility',
|
||||
CIC_CHECK: 'CIC / Credit Bureau Check',
|
||||
FIELD_INVESTIGATION: 'Field Investigation (FI)',
|
||||
PERSONAL_DISCUSSION: 'Personal Discussion with Manager',
|
||||
@@ -73,29 +70,6 @@ export const STAGE_LABELS: Record<OriginationStage, string> = {
|
||||
DISBURSED: 'Disbursed'
|
||||
};
|
||||
|
||||
/** The stages a given product actually goes through (a loan against a deposit has no CIC and no field investigation): Deposit Selection only for a loan against a deposit, and no
|
||||
* credit bureau (CIC) check for it. Everything else keeps the full 12-stage list. */
|
||||
export function stagesFor(productCode: string | undefined | null): OriginationStage[] {
|
||||
if (isDepositLoanCode(productCode)) {
|
||||
// No credit bureau check, field investigation or security cheque / ACH (recovery is by set-off against the lien), and no
|
||||
// separate risk or legal approval: the one Sanction step (the committee stage) takes their place. Every other stage stays.
|
||||
return STAGE_ORDER.filter(
|
||||
(stage) =>
|
||||
stage !== 'CIC_CHECK' &&
|
||||
stage !== 'FIELD_INVESTIGATION' &&
|
||||
stage !== 'CHEQUE_ACH' &&
|
||||
stage !== 'RISK_APPROVAL' &&
|
||||
stage !== 'LEGAL_APPROVAL'
|
||||
);
|
||||
}
|
||||
return STAGE_ORDER.filter((stage) => stage !== 'DEPOSIT_SELECTION');
|
||||
}
|
||||
|
||||
/** What a stage is called for this product: on a loan against a deposit the committee stage is the single Sanction step. */
|
||||
export function stageLabelFor(stage: OriginationStage, productCode: string | undefined | null): string {
|
||||
return stage === 'CREDIT_COMMITTEE' && isDepositLoanCode(productCode) ? 'Sanction' : STAGE_LABELS[stage];
|
||||
}
|
||||
|
||||
export function stageIndex(stage: OriginationStage): number {
|
||||
return STAGE_ORDER.indexOf(stage);
|
||||
}
|
||||
@@ -162,14 +136,13 @@ export const DISCREPANCY_LABELS: Record<DiscrepancyType, string> = {
|
||||
FAKE_DECLARATION: 'Fake Declaration Suspected'
|
||||
};
|
||||
|
||||
export type ApprovalDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED' | 'REVOKED';
|
||||
export type ApprovalDecision = 'PENDING' | 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED';
|
||||
|
||||
export const APPROVAL_LABELS: Record<ApprovalDecision, string> = {
|
||||
PENDING: 'Pending',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
EXCEPTION_APPROVED: 'Approved on Exception',
|
||||
REVOKED: 'Returned for Rectification'
|
||||
EXCEPTION_APPROVED: 'Approved on Exception'
|
||||
};
|
||||
|
||||
export type VerificationStatus = 'PENDING' | 'VERIFIED' | 'FAILED';
|
||||
@@ -207,89 +180,7 @@ export interface StageHistoryEntry {
|
||||
remarks: string;
|
||||
}
|
||||
|
||||
/** A LIC policy pledged for a loan against LIC: not a bank account, so the officer records it from the policy documents. */
|
||||
export interface LicPolicy {
|
||||
policyNo: string;
|
||||
insurer: string;
|
||||
policyHolder: string;
|
||||
sumAssured: number;
|
||||
premiumPaid: number;
|
||||
/** As per the insurer's surrender value letter (not older than a month). */
|
||||
surrenderValue: number;
|
||||
/** DD-MM-YYYY */
|
||||
maturityDate: string;
|
||||
/** The loan's interest rate, entered by the officer (a policy has no deposit rate to follow). */
|
||||
loanRate: number;
|
||||
}
|
||||
|
||||
/** One guarantor named on an application. */
|
||||
export interface GuarantorEntry {
|
||||
clientId: number;
|
||||
name: string;
|
||||
hasShareAccount: boolean;
|
||||
/** The share accounts found for the guarantor when membership was checked. */
|
||||
shareAccountNos?: string[];
|
||||
}
|
||||
|
||||
/** The deposit(s) pledged for a loan against FD / RD / DDS / LIC and what they allow (policy 9.5). */
|
||||
export interface DepositSelection {
|
||||
/** The LIC policy, for a loan against LIC (then `accountIds` is empty and `accountNos` holds the policy number). */
|
||||
policy?: LicPolicy;
|
||||
accountIds: number[];
|
||||
accountNos: string[];
|
||||
securityValue: number;
|
||||
limitPct: number;
|
||||
eligibleAmount: number;
|
||||
depositRate: number;
|
||||
spread: number;
|
||||
maxTenure: number;
|
||||
over85: boolean;
|
||||
over85Reason: string;
|
||||
thirdParty: boolean;
|
||||
/** The product requires a lien on the deposit (a product can switch this off). */
|
||||
lienRequired: boolean;
|
||||
saved: boolean;
|
||||
}
|
||||
|
||||
/** Lien noted on the pledged deposit(s) for this application. */
|
||||
export interface LienRecord {
|
||||
marked: boolean;
|
||||
certificateNo: string;
|
||||
markedOn: string;
|
||||
amount: number;
|
||||
accountNos: string[];
|
||||
/** Whether the lien really stands in core banking (funds held on the FD / RD, or the DDS account marked), as opposed to
|
||||
* only being recorded here. */
|
||||
coreStatus?: 'PLACED' | 'FAILED' | 'NOT_APPLICABLE';
|
||||
/** What was done in core banking, or why it could not be. */
|
||||
coreNote?: string;
|
||||
}
|
||||
|
||||
/** An approval sent for a decision (maker-checker): the user who sent it cannot be the one who decides. */
|
||||
export interface ApprovalRequest {
|
||||
status: 'PENDING' | 'APPROVED' | 'EXCEPTION_APPROVED' | 'REJECTED' | 'WITHDRAWN' | 'REVOKED';
|
||||
submittedBy: string;
|
||||
submittedOn: string;
|
||||
requestedDecision: 'APPROVED' | 'EXCEPTION_APPROVED';
|
||||
remarks: string;
|
||||
revokeRemarks?: string;
|
||||
previousRevokeRemarks?: string;
|
||||
previousRevokedBy?: string;
|
||||
previousRevokedOn?: string;
|
||||
resolutionNo?: string;
|
||||
resolutionDate?: string;
|
||||
/** What the sender had verified at the moment of sending -- what the approver reviews. */
|
||||
checklist?: { label: string; ok: boolean }[];
|
||||
/** What the approver ticked as checked herself/himself before approving. */
|
||||
checkerChecklist?: { label: string; ok: boolean }[];
|
||||
decidedBy?: string;
|
||||
decidedOn?: string;
|
||||
}
|
||||
|
||||
export interface LoanApplication {
|
||||
approvalRequests?: Partial<Record<string, ApprovalRequest>>;
|
||||
depositSelection?: DepositSelection;
|
||||
lien?: LienRecord;
|
||||
/** Database row id, assigned on save. Uploaded files hang off this (documents are stored against
|
||||
* the application's entity id), so it's absent only on an application not yet persisted. */
|
||||
id?: number;
|
||||
@@ -426,13 +317,6 @@ export interface LoanApplication {
|
||||
guarantorClientId: number | null;
|
||||
guarantorName: string;
|
||||
guarantorHasShareAccount: boolean;
|
||||
/** How many guarantors this application has to name, from the product it was raised on. Absent on an application raised
|
||||
* before products carried it, which keeps the officer's own Guarantor Required tick. */
|
||||
guarantorsRequiredCount?: number;
|
||||
/** Whether those guarantors have to hold a share account (be members), from the product. */
|
||||
guarantorMustBeMember?: boolean;
|
||||
/** The guarantors named so far (each a real, already-onboarded bank client). */
|
||||
guarantors?: GuarantorEntry[];
|
||||
|
||||
// "Reports Required" presented at Loan Committee, and the "Verification Checklist" run at Risk
|
||||
// Approval — kept as their own fields since ID/Address/Income/FI-Report are already covered by
|
||||
@@ -452,22 +336,9 @@ export interface LoanApplication {
|
||||
* is what actually gets quoted in the sanction letter later. */
|
||||
committeeResolutionNo: string;
|
||||
committeeResolutionDate: string;
|
||||
/** What the loan is for, asked after KYC: the priority sector and weaker section decisions follow from it. */
|
||||
loanPurpose?: string;
|
||||
/** The officer's priority sector decision ('yes' | 'no'); the product's own setting is only the starting point. */
|
||||
pslClassified?: string;
|
||||
/** Priority sector lending category (RBI Master Directions para 5); one only, set when classified as priority sector. */
|
||||
prioritySectorCategory?: string;
|
||||
/** Why the decision differs from the product's priority sector setting. */
|
||||
pslOverrideReason?: string;
|
||||
/** Weaker section ('yes' | 'no') and its type (RBI Master Directions para 17.1). */
|
||||
weakerSection?: string;
|
||||
weakerSectionType?: string;
|
||||
riskApproval: ApprovalRecord;
|
||||
legalApproval: ApprovalRecord;
|
||||
postCheckApproval: ApprovalRecord;
|
||||
/** The approval of the loan account's disbursement (asked at the Disbursement stage; absent until approved). */
|
||||
disbursementApproval?: ApprovalRecord;
|
||||
|
||||
// Legal / documentation
|
||||
feesAndAccountOpened: boolean;
|
||||
@@ -580,176 +451,7 @@ interface OriginationProduct {
|
||||
/** Minimum Share Capital the borrower must hold, as a % of the loan amount, per bank bye-laws
|
||||
* (a UCB norm -- membership/share-linkage against borrowing, distinct from margin/collateral). */
|
||||
shareLinkagePercent: number;
|
||||
/** Loan against a deposit: the share of the deposit value that can be lent (policy 9.5.5: 85%). */
|
||||
ltvPct?: number;
|
||||
/** Loan against a deposit: percentage points added to the deposit's own rate. */
|
||||
spread?: number;
|
||||
/** Loan against a deposit: when the lien is marked, as set on the product ("disbursement" or "sanction"). */
|
||||
lienStage?: string;
|
||||
lienRequired?: boolean;
|
||||
/** What the eligible amount is calculated on: "balance_accrued" (balance + accrued interest) or "balance". */
|
||||
eligibleBasis?: string;
|
||||
/** "deposit_spread" = the deposit's own rate plus the spread; anything else uses the product's own rate. */
|
||||
interestBasis?: string;
|
||||
/** The product's own interest rate (used when the rate is not "deposit rate + spread"). */
|
||||
baseRate?: number;
|
||||
/** The deposit product(s) a loan is lent against, as named on the loan product (Product Creation > Security & Lien). */
|
||||
eligibleDepositProducts?: string;
|
||||
/** How many guarantors an application on this product has to name (Product Creation); absent on an older product. */
|
||||
guarantorsRequired?: number;
|
||||
guarantorMustBeMember?: boolean;
|
||||
allowMultipleDeposits?: boolean;
|
||||
allowThirdParty?: boolean;
|
||||
/** The loan tenure may not run past the deposit's maturity. */
|
||||
tenureWithinMaturity?: boolean;
|
||||
certificateRequired?: boolean;
|
||||
certificatePrefix?: string;
|
||||
/** Terms shown on the loan plan of a deposit loan. */
|
||||
penalRate?: number;
|
||||
penalApplicable?: boolean;
|
||||
repaymentType?: string;
|
||||
interestPostingFrequency?: string;
|
||||
facilityType?: string;
|
||||
preclosure?: string;
|
||||
renewalAllowed?: boolean;
|
||||
/** The product was created as priority sector lending (Product Creation): the sanction names the category. */
|
||||
prioritySector?: boolean;
|
||||
}
|
||||
|
||||
/** RBI's priority sector lending categories. */
|
||||
export const PRIORITY_SECTOR_CATEGORIES = [
|
||||
'Agriculture',
|
||||
'Micro, Small and Medium Enterprises',
|
||||
'Export Credit',
|
||||
'Education',
|
||||
'Housing',
|
||||
'Social Infrastructure',
|
||||
'Renewable Energy',
|
||||
'Others'
|
||||
];
|
||||
/** Weaker section types (RBI Master Directions on PSL, para 17.1): the fallback when the Code "WeakerSectionType" cannot be read. */
|
||||
export const WEAKER_SECTION_TYPES = [
|
||||
'Small and Marginal Farmers',
|
||||
'Artisans, village and cottage industries (individual credit limit up to ₹2 lakh)',
|
||||
'Beneficiaries of Government Sponsored Schemes (NRLM, SRMS)',
|
||||
'Scheduled Castes and Scheduled Tribes',
|
||||
'Beneficiaries of Differential Rate of Interest (DRI) scheme',
|
||||
'Self Help Groups / Joint Liability Groups',
|
||||
'Individuals and members of SHGs/JLGs availing microfinance loans',
|
||||
'Individual women beneficiaries (up to ₹2 lakh)',
|
||||
'Distressed farmers indebted to non-institutional lenders',
|
||||
'Distressed persons other than farmers (up to ₹1 lakh)',
|
||||
'Persons with disabilities',
|
||||
'Transgenders',
|
||||
'Minority communities notified by Government of India'
|
||||
];
|
||||
|
||||
/** Proof an officer can attach for a priority sector classification, by category. */
|
||||
export const PSL_DOCUMENT_TYPES: Record<string, string[]> = {
|
||||
Agriculture: [
|
||||
'Land record (7/12, Khata, Patta)',
|
||||
'Crop / cultivation details',
|
||||
'Kisan Credit Card',
|
||||
'Tenancy / lease agreement',
|
||||
'Allied activity licence (dairy, fisheries, poultry)',
|
||||
'Project report / quotation'
|
||||
],
|
||||
'Micro, Small and Medium Enterprises': [
|
||||
'Udyam Registration Certificate',
|
||||
'GST registration',
|
||||
'Shop & Establishment / trade licence',
|
||||
'Project report / quotation',
|
||||
'Latest ITR / financial statements'
|
||||
],
|
||||
'Export Credit': [
|
||||
'Export order / Letter of credit',
|
||||
'Import Export Code (IEC)',
|
||||
'Shipping bill / invoice'
|
||||
],
|
||||
Education: [
|
||||
'Admission letter',
|
||||
'Fee structure',
|
||||
'Marksheet of the last exam'
|
||||
],
|
||||
Housing: [
|
||||
'Sale agreement / allotment letter',
|
||||
'Property title documents',
|
||||
'Cost estimate / approved plan',
|
||||
'Property tax receipt (for repairs)'
|
||||
],
|
||||
'Social Infrastructure': [
|
||||
'School / hospital registration',
|
||||
'Project report and approvals',
|
||||
'Land / building documents'
|
||||
],
|
||||
'Renewable Energy': [
|
||||
'Project report',
|
||||
'Vendor quotation',
|
||||
'DISCOM / MNRE approval'
|
||||
],
|
||||
Others: [
|
||||
'Self Help Group registration / resolution',
|
||||
'Start-up recognition (DPIIT)',
|
||||
'Debt statement (distressed person)',
|
||||
'Project report'
|
||||
]
|
||||
};
|
||||
|
||||
/** Offered for every category and for the weaker section proof. */
|
||||
export const PSL_OTHER_DOCUMENT = 'Other supporting document';
|
||||
|
||||
/** Proof of the weaker section the borrower belongs to. */
|
||||
export const WEAKER_SECTION_DOCUMENT_TYPES = [
|
||||
'Caste certificate (SC/ST)',
|
||||
'Disability certificate / UDID',
|
||||
'Minority community proof',
|
||||
'Landholding record (small / marginal farmer)',
|
||||
'SHG / JLG registration',
|
||||
'Government scheme beneficiary certificate (NRLM, SRMS, DRI)',
|
||||
'Artisan card / village industry registration',
|
||||
'Debt statement (distressed farmer / person)',
|
||||
'Aadhaar (women / transgender beneficiary)',
|
||||
PSL_OTHER_DOCUMENT
|
||||
];
|
||||
|
||||
/** Short names of the loans against a deposit or policy (FD, RD, DDS, LIC): the deposit is the security, so income
|
||||
* proof, bank statement, address proof and a co-borrower are optional and there is no credit bureau check. */
|
||||
export const DEPOSIT_PRODUCT_CODES = [
|
||||
'FD',
|
||||
'RD',
|
||||
'DS',
|
||||
'LI'
|
||||
];
|
||||
|
||||
/** A loan against a LIC policy: the security is a policy recorded on the application, not a deposit account. */
|
||||
export function isLicLoanCode(productCode: string | undefined | null): boolean {
|
||||
return productCode === 'LI' || (!!productCode && licProductCodes.has(productCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Short names of saved products whose Product Creation sub type is a deposit or policy (fd, rd, dds, lic). Filled by
|
||||
* registerOriginationProduct, so a product made later under any short name (a second FD product) is also a loan
|
||||
* against a deposit; the fixed codes above stay as the answer for the original products.
|
||||
*/
|
||||
const depositSubTypeCodes = new Set<string>();
|
||||
const licProductCodes = new Set<string>();
|
||||
const depositKindBySubType: Record<string, string> = { fd: 'FD', rd: 'RD', dds: 'DS', lic: 'LI' };
|
||||
const depositKindByCode = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* The kind of deposit a product is a loan against, as the original short name of that kind: 'FD', 'RD', 'DS' (daily
|
||||
* deposit scheme) or 'LI' (LIC policy). A product made later under another short name answers by its sub type, so
|
||||
* screens that tell the kinds apart treat it like the original; any other product gets its own code back.
|
||||
*/
|
||||
export function depositKindCode(productCode: string | undefined | null): string {
|
||||
const code = productCode ?? '';
|
||||
return DEPOSIT_PRODUCT_CODES.includes(code) ? code : (depositKindByCode.get(code) ?? code);
|
||||
}
|
||||
|
||||
export function isDepositLoanCode(productCode: string | undefined | null): boolean {
|
||||
return !!productCode && (DEPOSIT_PRODUCT_CODES.includes(productCode) || depositSubTypeCodes.has(productCode));
|
||||
}
|
||||
|
||||
export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
|
||||
{
|
||||
code: 'PL',
|
||||
@@ -884,53 +586,6 @@ interface ProductDocumentTemplate {
|
||||
* encumbering that asset has to be collected before the loan can be sanctioned, on top of the
|
||||
* borrower's own identity/income proof. */
|
||||
export const PRODUCT_DOCUMENT_REQUIREMENTS: Record<string, ProductDocumentTemplate[]> = {
|
||||
FD: [
|
||||
{
|
||||
key: 'fdReceipt',
|
||||
label: 'FD receipt — original, discharged on the back',
|
||||
hint: 'Signed by the depositor'
|
||||
},
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
|
||||
{
|
||||
key: 'letterOfContinuity',
|
||||
label: 'Letter of continuity',
|
||||
hint: 'Liability and security continue until the loan is repaid'
|
||||
}
|
||||
],
|
||||
RD: [
|
||||
{ key: 'rdPassbook', label: 'RD passbook', hint: 'Kept by the bank' },
|
||||
{
|
||||
key: 'adjustDeclaration',
|
||||
label: 'Declaration: bank may adjust the loan on RD maturity',
|
||||
hint: 'Bank may adjust the loan from the deposit on maturity'
|
||||
},
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
|
||||
{
|
||||
key: 'letterOfContinuity',
|
||||
label: 'Letter of continuity',
|
||||
hint: 'Liability and security continue until the loan is repaid'
|
||||
}
|
||||
],
|
||||
DS: [
|
||||
{ key: 'ddsPassbook', label: 'DDS passbook', hint: 'Kept by the bank' },
|
||||
{
|
||||
key: 'adjustDeclaration',
|
||||
label: 'Declaration: bank may adjust the loan on DDS maturity',
|
||||
hint: 'Bank may adjust the loan from the deposit on maturity'
|
||||
},
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
|
||||
{
|
||||
key: 'letterOfContinuity',
|
||||
label: 'Letter of continuity',
|
||||
hint: 'Liability and security continue until the loan is repaid'
|
||||
}
|
||||
],
|
||||
LI: [
|
||||
{ key: 'licPolicyBond', label: 'Original LIC policy bond', hint: 'Kept by the bank' },
|
||||
{ key: 'licAssignment', label: 'Assignment deed in favour of the bank', hint: 'Registered with the LIC branch' },
|
||||
{ key: 'licSurrenderLetter', label: 'LIC surrender value letter', hint: 'Not older than 1 month' },
|
||||
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' }
|
||||
],
|
||||
HL: [
|
||||
{ key: 'propertyTaxReceipt', label: 'Property Tax Receipt' },
|
||||
{ key: 'propertyRegistration', label: 'Property Registration Document (Sale Deed)' },
|
||||
@@ -981,100 +636,15 @@ export function buildProductDocuments(productCode: string): ProductDocumentEntry
|
||||
return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a product saved in Product Creation usable by the application screens: the detail page, the eligibility
|
||||
* check and the Fineract bridge all look a product up by its code in ORIGINATION_PRODUCTS, so a saved product is
|
||||
* registered there under its Short Name (replacing an earlier registration of the same code).
|
||||
*/
|
||||
export function registerOriginationProduct(p: any): OriginationProduct {
|
||||
const ext = p?.advanceExtensions ?? {};
|
||||
const shortName = String(p.shortName);
|
||||
if ([
|
||||
'fd',
|
||||
'rd',
|
||||
'dds',
|
||||
'lic'
|
||||
].includes(ext.productTypeSubGroup)) {
|
||||
depositSubTypeCodes.add(shortName);
|
||||
depositKindByCode.set(shortName, depositKindBySubType[ext.productTypeSubGroup]);
|
||||
} else {
|
||||
depositSubTypeCodes.delete(shortName);
|
||||
depositKindByCode.delete(shortName);
|
||||
}
|
||||
if (ext.productTypeSubGroup === 'lic') {
|
||||
licProductCodes.add(shortName);
|
||||
} else {
|
||||
licProductCodes.delete(shortName);
|
||||
}
|
||||
const inMonths = !p?.repaymentFrequencyType?.value || p.repaymentFrequencyType.value === 'Months';
|
||||
const rate = Number(p?.interestRatePerPeriod ?? 0);
|
||||
const entry: OriginationProduct = {
|
||||
code: String(p.shortName),
|
||||
name: String(p.name).replace(/\s+/g, ' ').trim(),
|
||||
secured: !isDepositLoanCode(String(p.shortName)) && (ext.productTypeGroup === 'advances' || !!ext.securityType),
|
||||
minAmt: Number(p?.minPrincipal ?? p?.principal ?? 0),
|
||||
maxAmt: Number(p?.maxPrincipal ?? p?.principal ?? 0),
|
||||
minRate: Number(p?.minInterestRatePerPeriod ?? rate),
|
||||
maxRate: Number(p?.maxInterestRatePerPeriod ?? rate),
|
||||
minTenure: Number(inMonths ? (p?.minNumberOfRepayments ?? p?.numberOfRepayments ?? 1) : 1),
|
||||
maxTenure: Number(inMonths ? (p?.maxNumberOfRepayments ?? p?.numberOfRepayments ?? 360) : 360),
|
||||
incomeMultiplier: 20,
|
||||
shareLinkagePercent: 5,
|
||||
ltvPct: Number(ext.ltv ?? 85),
|
||||
spread: Number(ext.spread ?? 0),
|
||||
lienStage: String(ext.lienMarkingStage ?? 'disbursement'),
|
||||
lienRequired: ext.lienRequired !== false,
|
||||
eligibleBasis: String(ext.eligibleAmountBasis ?? 'balance_accrued'),
|
||||
interestBasis: String(ext.interestBasis ?? 'deposit_spread'),
|
||||
baseRate: rate,
|
||||
eligibleDepositProducts: ext.eligibleDepositProducts ? String(ext.eligibleDepositProducts) : undefined,
|
||||
guarantorsRequired:
|
||||
ext.guarantorsRequired === undefined || ext.guarantorsRequired === null || ext.guarantorsRequired === ''
|
||||
? undefined
|
||||
: Number(ext.guarantorsRequired),
|
||||
guarantorMustBeMember: ext.guarantorMustBeMember === undefined ? undefined : ext.guarantorMustBeMember === true,
|
||||
allowMultipleDeposits: ext.lienAllowMultipleDeposits === true,
|
||||
allowThirdParty: ext.lienAllowThirdParty === true,
|
||||
tenureWithinMaturity: ext.lienTenureNotExceedMaturity !== false,
|
||||
certificateRequired: ext.lienCertificateRequired !== false && ext.lienCertificateRequired !== 'NO',
|
||||
certificatePrefix: ext.lienCertificatePrefix ? String(ext.lienCertificatePrefix) : undefined,
|
||||
penalRate: Number(ext.penalRate ?? 0),
|
||||
penalApplicable: ext.penalApplicable === true,
|
||||
repaymentType: String(ext.repaymentType ?? 'bullet'),
|
||||
interestPostingFrequency: String(ext.interestPostingFrequency ?? 'Monthly'),
|
||||
facilityType: String(ext.facilityType ?? 'demand'),
|
||||
preclosure: String(ext.preclosureAllowed ?? 'anytime'),
|
||||
renewalAllowed: ext.renewalAllowed === true,
|
||||
prioritySector: String(ext.prioritySector ?? '').toLowerCase() === 'yes'
|
||||
};
|
||||
const at = ORIGINATION_PRODUCTS.findIndex((x) => x.code === entry.code);
|
||||
if (at >= 0) {
|
||||
ORIGINATION_PRODUCTS[at] = entry;
|
||||
} else {
|
||||
ORIGINATION_PRODUCTS.push(entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** What Legal Approval actually collects proof of -- the executed agreement, the stamp duty paid
|
||||
* on it, and (for a secured product) the mortgage/title paperwork -- same "Add More Doc" pattern
|
||||
* as the product-specific documents above, since a specific matter can always need one more
|
||||
* paper (a second mortgagor's consent letter, a court NOC, etc.) than this starter list expects. */
|
||||
export function buildLegalDocuments(secured: boolean, depositLoan = false): ProductDocumentEntry[] {
|
||||
if (depositLoan) {
|
||||
// The DP Note, letter of continuity, deposit receipt and letter of lien were collected earlier (KYC & Documents and
|
||||
// Lien Marking); only what is new at the legal stage is asked here. No mortgage, title or legal opinion: the security is a deposit.
|
||||
return [
|
||||
{
|
||||
key: 'stampDutyReceipt',
|
||||
label: 'Stamp duty / revenue stamp receipt (on the DP Note and agreement)'
|
||||
}
|
||||
].map((doc) => ({ ...doc, custom: false, submitted: false }));
|
||||
}
|
||||
export function buildLegalDocuments(secured: boolean): ProductDocumentEntry[] {
|
||||
const templates: ProductDocumentTemplate[] = [
|
||||
{ key: 'loanAgreement', label: 'Loan Agreement (Registered/Notarized)' },
|
||||
{ key: 'stampDutyReceipt', label: 'Legal Stamp Paper / Stamp Duty Receipt' },
|
||||
{ key: 'feeReceipt', label: 'Processing Fee Receipt' }
|
||||
{ key: 'feeReceipt', label: 'Fee & Account Opening Receipt' }
|
||||
];
|
||||
if (secured) {
|
||||
templates.push(
|
||||
@@ -1222,11 +792,7 @@ export function progressPct(app: LoanApplication): number {
|
||||
if (app.overallStatus === 'DISBURSED') {
|
||||
return 100;
|
||||
}
|
||||
const stages = stagesFor(app.productCode);
|
||||
const at = stages.indexOf(app.currentStage);
|
||||
return at >= 0
|
||||
? round(((at + 1) / stages.length) * 100)
|
||||
: round(((stageIndex(app.currentStage) + 1) / STAGE_ORDER.length) * 100);
|
||||
return round(((stageIndex(app.currentStage) + 1) / STAGE_ORDER.length) * 100);
|
||||
}
|
||||
|
||||
export function stageBadgeClass(app: LoanApplication): string {
|
||||
@@ -1970,19 +1536,6 @@ export function decideApproval(
|
||||
approverRole: string,
|
||||
remarks: string
|
||||
): void {
|
||||
if (decision === 'REVOKED') {
|
||||
const req = app.approvalRequests?.[field];
|
||||
if (req) {
|
||||
req.status = 'REVOKED';
|
||||
req.decidedBy = approverName;
|
||||
req.decidedOn = todayDDMMYYYY();
|
||||
req.remarks = remarks;
|
||||
req.revokeRemarks = remarks;
|
||||
}
|
||||
delete app[field];
|
||||
recordStageProgress(app, approverName, 'Returned for Rectification', remarks);
|
||||
return;
|
||||
}
|
||||
app[field] = { decision, approverName, approverRole, date: todayDDMMYYYY(), remarks };
|
||||
if (decision === 'REJECTED') {
|
||||
app.overallStatus = 'REJECTED';
|
||||
@@ -2286,7 +1839,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
const actionBy =
|
||||
input.channel === 'BC' ? input.bcAgentName : input.channel === 'SELF_APPLY' ? 'Customer (Digital)' : 'Branch Staff';
|
||||
return {
|
||||
applicationNo: `LN${input.branchCode.replace(/^(OFF|BR)/, '').padStart(2, '0')}${input.productCode}${String(seq).padStart(6, '0')}`,
|
||||
applicationNo: `LN${input.branchCode.slice(2)}${input.productCode}${String(seq).padStart(6, '0')}`,
|
||||
applicationDate: todayDDMMYYYY(),
|
||||
applicantName: input.applicantName,
|
||||
branchCode: input.branchCode,
|
||||
@@ -2358,11 +1911,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
personalDiscussionDate: '—',
|
||||
declarationTaken: false,
|
||||
declarationDate: '—',
|
||||
// What the product asks for; an older product that does not say leaves it to the officer's own tick at Risk Approval.
|
||||
guarantorRequired: (ORIGINATION_PRODUCTS.find((p) => p.code === input.productCode)?.guarantorsRequired ?? 0) > 0,
|
||||
guarantorsRequiredCount: ORIGINATION_PRODUCTS.find((p) => p.code === input.productCode)?.guarantorsRequired,
|
||||
guarantorMustBeMember: ORIGINATION_PRODUCTS.find((p) => p.code === input.productCode)?.guarantorMustBeMember,
|
||||
guarantors: [],
|
||||
guarantorRequired: false,
|
||||
guarantorClientId: null,
|
||||
guarantorName: '—',
|
||||
guarantorHasShareAccount: false,
|
||||
@@ -2383,7 +1932,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
|
||||
legalStampDone: false,
|
||||
fileFilingDone: false,
|
||||
agreementSigningSlot: '—',
|
||||
legalDocuments: buildLegalDocuments(input.secured, isDepositLoanCode(input.productCode)),
|
||||
legalDocuments: buildLegalDocuments(input.secured),
|
||||
customerSigningDate: '—',
|
||||
chequeReceived: false,
|
||||
achSigned: false,
|
||||
|
||||
@@ -430,18 +430,14 @@
|
||||
// one long stacked column, so a 6-7 item checklist reads as a glanceable board, not a scroll.
|
||||
.loi-kyc-doc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.loi-kyc-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-self: start;
|
||||
height: auto;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
@@ -869,16 +865,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.loi-legal-doc-thumb-remove.loi-bank-remove {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 999px;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// CIC Credit Bureau Hero Card in Stage detail
|
||||
.loi-cic-hero-card {
|
||||
display: flex;
|
||||
@@ -992,212 +978,3 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-optional-tag {
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.loi-deposit-note {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 8px;
|
||||
background: #f0f9ff;
|
||||
color: #075985;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loi-dep-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.loi-dep-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
font-size: 13.5px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.r {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
tr.loi-dep-selected td {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
tr.loi-dep-disabled td {
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-dep-calc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
margin: 12px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.loi-dep-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13.5px;
|
||||
color: #334155;
|
||||
|
||||
b {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #0f172a;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-dep-total {
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
font-size: 15px;
|
||||
|
||||
b {
|
||||
color: #15803d;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-dep-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
@media (width <= 700px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-deposit-ok {
|
||||
margin: 6px 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 8px;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loi-lien-cert {
|
||||
margin: 12px 0;
|
||||
padding: 14px 18px;
|
||||
border: 2px dashed #94a3b8;
|
||||
border-radius: 10px;
|
||||
background: #fffef7;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.pg-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loi-approval-wait {
|
||||
margin: 10px 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 8px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.loi-verified {
|
||||
margin: 8px 0 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
|
||||
ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 2px 0;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.loi-verified--no {
|
||||
color: #b91c1c;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-locked {
|
||||
pointer-events: none;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.loi-checker {
|
||||
margin: 8px 0 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 8px;
|
||||
background: #eef2ff;
|
||||
font-size: 13px;
|
||||
color: #1e1b4b;
|
||||
}
|
||||
|
||||
/* A stage sent for approval: files can still be opened (View), nothing can be added, replaced or removed. */
|
||||
.loi-docs-locked {
|
||||
label.pg-btn,
|
||||
.loi-legal-doc-thumb-remove,
|
||||
.loi-doc-thumb-remove,
|
||||
.loi-doc-remove,
|
||||
.loi-custom-doc-input,
|
||||
.loan-action-row {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.loi-view-link {
|
||||
margin-left: 8px;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -8,38 +8,8 @@
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, shareReplay, switchMap } from 'rxjs/operators';
|
||||
import {
|
||||
BranchMaster,
|
||||
LoanApplication,
|
||||
PRIORITY_SECTOR_CATEGORIES,
|
||||
WEAKER_SECTION_TYPES
|
||||
} from './loan-origination-data';
|
||||
|
||||
/** The core banking loan account an application has been given, with where it stands. */
|
||||
export interface LoanAccountState {
|
||||
id: number;
|
||||
accountNo: string;
|
||||
status: string;
|
||||
pendingApproval: boolean;
|
||||
/** Disbursed and running (the loan has money out). */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** What an opened loan account shows on the stages after signing. */
|
||||
export interface AccountOverview {
|
||||
account: { label: string; value: string }[];
|
||||
approval: { by: string; on: string; amount: string };
|
||||
charges: { name: string; amount: string; when: string; added: boolean }[];
|
||||
glAccounts: { label: string; value: string }[];
|
||||
posting: string | null;
|
||||
}
|
||||
|
||||
/** The code a branch carries on an application: the Fineract office it belongs to (office 1 is "OFF1"). */
|
||||
export function officeBranchCode(officeId: number | string): string {
|
||||
return `OFF${officeId}`;
|
||||
}
|
||||
import { Observable } from 'rxjs';
|
||||
import { LoanApplication } from './loan-origination-data';
|
||||
|
||||
/**
|
||||
* Persistence for the Loan Origination Pipeline, in two deliberately different shapes.
|
||||
@@ -59,171 +29,6 @@ export class LoanOriginationService {
|
||||
|
||||
private readonly baseUrl = '/loan-origination/applications';
|
||||
|
||||
private readonly codeLists = new Map<string, Observable<string[]>>();
|
||||
|
||||
/**
|
||||
* The active values of a system Code (Admin > Codes), in their order. The built-in list is the fallback only when the
|
||||
* Code cannot be read or has no values.
|
||||
*/
|
||||
private codeNames(codeName: string, fallback: string[]): Observable<string[]> {
|
||||
let list = this.codeLists.get(codeName);
|
||||
if (!list) {
|
||||
list = this.http.get<any[]>('/codes').pipe(
|
||||
switchMap((codes) => {
|
||||
const code = (codes ?? []).find((c) => c.name === codeName);
|
||||
return code ? this.http.get<any[]>(`/codes/${code.id}/codevalues`) : of([] as any[]);
|
||||
}),
|
||||
map((values) =>
|
||||
(values ?? [])
|
||||
.filter((v) => v.active !== false && v.isActive !== false)
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
||||
.map((v) => String(v.name))
|
||||
),
|
||||
map((names) => (names.length ? names : fallback)),
|
||||
catchError(() => of(fallback)),
|
||||
shareReplay(1)
|
||||
);
|
||||
this.codeLists.set(codeName, list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** The priority sector lending categories (Code "PrioritySectorCategory"). */
|
||||
prioritySectorCategories(): Observable<string[]> {
|
||||
return this.codeNames('PrioritySectorCategory', PRIORITY_SECTOR_CATEGORIES);
|
||||
}
|
||||
|
||||
/** The weaker section types (Code "WeakerSectionType"). */
|
||||
weakerSectionTypes(): Observable<string[]> {
|
||||
return this.codeNames('WeakerSectionType', WEAKER_SECTION_TYPES);
|
||||
}
|
||||
|
||||
/** One fixed or recurring deposit account with its balance, rate and maturity. */
|
||||
getDepositAccount(kind: 'FD' | 'RD', accountId: number): Observable<any> {
|
||||
return this.http.get(`/${kind === 'FD' ? 'fixeddepositaccounts' : 'recurringdepositaccounts'}/${accountId}`);
|
||||
}
|
||||
|
||||
/** A customer's open Daily Deposit Scheme accounts (the bank's DDS module keeps these, not Fineract's deposit accounts). */
|
||||
listDdsAccounts(clientId: number): Observable<any[]> {
|
||||
return this.http
|
||||
.get<any[]>('/dds/parties')
|
||||
.pipe(map((parties) => (parties ?? []).filter((p) => p.clientId === clientId && p.status !== 'CLOSED')));
|
||||
}
|
||||
|
||||
/**
|
||||
* The core banking loan account opened for this application, if any. The account screen gives the loan the application
|
||||
* number as its external id, so the two can be matched; null when no account has been created.
|
||||
*/
|
||||
loanForApplication(applicationNo: string): Observable<LoanAccountState | null> {
|
||||
// A search, not /loans/external-id/{id}: that answers 404 when there is no account, which the app shows as an error popup.
|
||||
return this.http.get<any>(`/loans?externalId=${encodeURIComponent(applicationNo)}&limit=1`).pipe(
|
||||
map((result) => result?.pageItems?.[0]),
|
||||
map((loan) =>
|
||||
loan?.id
|
||||
? {
|
||||
id: loan.id,
|
||||
accountNo: loan.accountNo,
|
||||
status: loan.status?.value ?? '—',
|
||||
pendingApproval: !!loan.status?.pendingApproval,
|
||||
active: !!loan.status?.active
|
||||
}
|
||||
: null
|
||||
),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything the Post-Disbursement / Disbursement stages show about the account that was opened for an application. */
|
||||
loanAccountOverview(loanId: number): Observable<AccountOverview> {
|
||||
return this.http.get<any>(`/loans/${loanId}?associations=charges`).pipe(
|
||||
switchMap((loan) =>
|
||||
this.http.get<any>(`/loanproducts/${loan.loanProductId}`).pipe(
|
||||
catchError(() => of({})),
|
||||
map((product) => {
|
||||
const date = (v: any) =>
|
||||
Array.isArray(v)
|
||||
? v
|
||||
.slice(0, 3)
|
||||
.reverse()
|
||||
.map((n: number) => String(n).padStart(2, '0'))
|
||||
.join('-')
|
||||
: (v ?? '—');
|
||||
const inr = (v: any) =>
|
||||
v === null || v === undefined || v === '' ? '—' : '₹' + Number(v).toLocaleString('en-IN');
|
||||
const txt = (v: any) => (v === null || v === undefined || v === '' ? '—' : String(v));
|
||||
const productChargeIds = new Set<number>((product.charges ?? []).map((c: any) => c.id));
|
||||
const gl = (account: any) => (account?.glCode ? account.glCode + ' · ' + account.name : '—');
|
||||
const maps = product.accountingMappings ?? {};
|
||||
const cash = product.accountingRule?.id === 2;
|
||||
return {
|
||||
account: [
|
||||
{ label: 'Account no.', value: txt(loan.accountNo) },
|
||||
{ label: 'Status', value: txt(loan.status?.value) },
|
||||
{ label: 'Product', value: txt(loan.loanProductName) },
|
||||
{ label: 'Customer', value: txt(loan.clientName) },
|
||||
{ label: 'Principal', value: inr(loan.principal) },
|
||||
{
|
||||
label: 'Interest rate',
|
||||
value: loan.annualInterestRate !== undefined ? loan.annualInterestRate + '% p.a.' : '—'
|
||||
},
|
||||
{
|
||||
label: 'Term',
|
||||
value:
|
||||
loan.termFrequency !== undefined
|
||||
? loan.termFrequency + ' ' + (loan.termPeriodFrequencyType?.value ?? '')
|
||||
: '—'
|
||||
},
|
||||
{ label: 'Purpose', value: txt(loan.loanPurposeName) },
|
||||
{ label: 'Submitted on', value: date(loan.timeline?.submittedOnDate) },
|
||||
{ label: 'Expected disbursement', value: date(loan.timeline?.expectedDisbursementDate) }
|
||||
],
|
||||
approval: {
|
||||
by: txt(loan.timeline?.approvedByUsername),
|
||||
on: date(loan.timeline?.approvedOnDate),
|
||||
amount: inr(loan.approvedPrincipal)
|
||||
},
|
||||
charges: (loan.charges ?? []).map((c: any) => ({
|
||||
name: c.name as string,
|
||||
amount: inr(c.amount),
|
||||
when: (c.chargeTimeType?.value ?? '—') as string,
|
||||
added: !productChargeIds.has(c.chargeId)
|
||||
})),
|
||||
glAccounts: [
|
||||
{ label: 'Fund source (paid out on disbursement)', value: gl(maps.fundSourceAccount) },
|
||||
{ label: "Loan portfolio (the customer's loan account)", value: gl(maps.loanPortfolioAccount) },
|
||||
{ label: 'Interest income', value: gl(maps.interestOnLoanAccount) },
|
||||
{ label: 'Fee income', value: gl(maps.incomeFromFeeAccount) },
|
||||
{ label: 'Penalty income', value: gl(maps.incomeFromPenaltyAccount) },
|
||||
{ label: 'Overpayment liability', value: gl(maps.overpaymentLiabilityAccount) },
|
||||
{ label: 'Write-off', value: gl(maps.writeOffAccount) }
|
||||
],
|
||||
posting: cash
|
||||
? 'On disbursement: Debit ' +
|
||||
gl(maps.loanPortfolioAccount) +
|
||||
' · Credit ' +
|
||||
gl(maps.fundSourceAccount) +
|
||||
' · ' +
|
||||
inr(loan.principal)
|
||||
: null
|
||||
} as AccountOverview;
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** The bank's real branches: every office of the organisation, in hierarchy order. */
|
||||
listBranches(): Observable<BranchMaster[]> {
|
||||
return this.http.get<{ id: number; name: string; hierarchy?: string }[]>('/offices').pipe(
|
||||
map((offices) =>
|
||||
(offices ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => (a.hierarchy ?? '').localeCompare(b.hierarchy ?? '') || a.id - b.id)
|
||||
.map((office) => ({ code: officeBranchCode(office.id), name: office.name }))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
list(): Observable<LoanApplication[]> {
|
||||
return this.http.get<LoanApplication[]>(this.baseUrl);
|
||||
}
|
||||
@@ -262,10 +67,6 @@ export class LoanOriginationService {
|
||||
return this.http.post<LoanApplication>(`${this.baseUrl}/${encodeURIComponent(applicationNo)}/${path}`, body);
|
||||
}
|
||||
|
||||
completeDepositSelection(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'deposit-selection-complete', { actionBy, remarks });
|
||||
}
|
||||
|
||||
completeKyc(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'kyc-complete', { actionBy, remarks });
|
||||
}
|
||||
@@ -317,37 +118,17 @@ export class LoanOriginationService {
|
||||
applicationNo: string,
|
||||
field: string,
|
||||
body: {
|
||||
decision: 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED' | 'REVOKED';
|
||||
decision: 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED';
|
||||
approverName: string;
|
||||
approverRole: string;
|
||||
remarks?: string;
|
||||
resolutionNo?: string;
|
||||
resolutionDate?: string;
|
||||
checkerChecklist?: { label: string; ok: boolean }[];
|
||||
}
|
||||
): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, `approvals/${field}`, body);
|
||||
}
|
||||
|
||||
submitApproval(
|
||||
applicationNo: string,
|
||||
field: string,
|
||||
body: {
|
||||
decision: 'APPROVED' | 'EXCEPTION_APPROVED';
|
||||
approverRole: string;
|
||||
remarks?: string;
|
||||
resolutionNo?: string;
|
||||
resolutionDate?: string;
|
||||
checklist?: { label: string; ok: boolean }[];
|
||||
}
|
||||
): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, `approvals/${field}/submit`, body);
|
||||
}
|
||||
|
||||
withdrawApproval(applicationNo: string, field: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, `approvals/${field}/withdraw`, {});
|
||||
}
|
||||
|
||||
markSigned(applicationNo: string, actionBy: string): Observable<LoanApplication> {
|
||||
return this.stage(applicationNo, 'signing', { actionBy });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,9 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
||||
import { ClientsService } from '../../clients/clients.service';
|
||||
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
|
||||
import { LoanOriginationService, officeBranchCode } from '../loan-origination.service';
|
||||
import { LoanProductCatalogService } from '../../loans/services/loan-product-catalog.service';
|
||||
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../products/loan-products/loan-product-types';
|
||||
import { LoanOriginationService } from '../loan-origination.service';
|
||||
import {
|
||||
BRANCHES,
|
||||
BranchMaster,
|
||||
branchTargetAmount,
|
||||
Channel,
|
||||
@@ -26,10 +25,10 @@ import {
|
||||
generateSeedApplications,
|
||||
LoanApplication,
|
||||
ORIGINATION_PRODUCTS,
|
||||
registerOriginationProduct,
|
||||
OriginationStage,
|
||||
OverallStatus,
|
||||
PRODUCT_TARGET_PCT,
|
||||
PRODUCT_TYPE_LABELS,
|
||||
ProductType,
|
||||
SHARE_LINKAGE_SLABS,
|
||||
STAGE_LABELS,
|
||||
@@ -37,7 +36,9 @@ import {
|
||||
STATUS_LABELS,
|
||||
TARGET_STATUS_LABELS,
|
||||
TargetStatus,
|
||||
allowedProductTypes,
|
||||
createApplication,
|
||||
defaultProductType,
|
||||
progressPct,
|
||||
stageBadgeClass,
|
||||
targetStatus,
|
||||
@@ -109,8 +110,7 @@ type ViewId = 'pipeline' | 'new' | 'channel' | 'bc' | 'share' | 'target' | 'rate
|
||||
'../../cashier/cashier-shared.scss',
|
||||
'../../investment/investment-shared.scss',
|
||||
'../../npa/npa-shared.scss',
|
||||
'../loan-origination-shared.scss',
|
||||
'./loan-pipeline.component.scss'
|
||||
'../loan-origination-shared.scss'
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
@@ -124,12 +124,7 @@ export class LoanPipelineComponent implements OnInit {
|
||||
readonly stageLabels = STAGE_LABELS;
|
||||
readonly statusLabels = STATUS_LABELS;
|
||||
readonly channelLabels = CHANNEL_LABELS;
|
||||
/** The bank's offices (real). Until they load, the logged-in user's own office is the only choice. */
|
||||
branches: BranchMaster[] = [];
|
||||
/** Active products saved in Product Creation -- the only products an application can be taken for. */
|
||||
dbProducts: any[] = [];
|
||||
loadingProducts = true;
|
||||
readonly productGroups = PRODUCT_TYPE_GROUPS;
|
||||
readonly branches = BRANCHES;
|
||||
readonly products = ORIGINATION_PRODUCTS;
|
||||
readonly shareLinkageSlabs = SHARE_LINKAGE_SLABS;
|
||||
asOnDate = todayDDMMYYYY();
|
||||
@@ -157,63 +152,26 @@ export class LoanPipelineComponent implements OnInit {
|
||||
customerType: 'NEW' as CustomerType,
|
||||
applicantName: '',
|
||||
clientId: null as number | null,
|
||||
branchCode: '',
|
||||
productGroup: '',
|
||||
productSubType: '',
|
||||
productCode: '',
|
||||
productType: 'TERM_LOAN' as ProductType,
|
||||
branchCode: this.branches[0].code,
|
||||
productCode: this.products[0].code,
|
||||
productType: defaultProductType(this.products[0].code),
|
||||
requestedAmount: null as number | null,
|
||||
tenureMonths: null as number | null
|
||||
};
|
||||
|
||||
/** Sub types of the chosen Product Type that actually have an active product saved. */
|
||||
get subTypeOptions() {
|
||||
const group = this.newApp.productGroup;
|
||||
return (PRODUCT_SUB_TYPES[group] ?? []).filter((t) =>
|
||||
this.dbProducts.some(
|
||||
(p) => p.advanceExtensions?.productTypeGroup === group && p.advanceExtensions?.productTypeSubGroup === t.id
|
||||
)
|
||||
);
|
||||
readonly productTypeLabels = PRODUCT_TYPE_LABELS;
|
||||
|
||||
/** Which Product Types the currently-selected product can be sanctioned as -- Cash Credit/KCC
|
||||
* are Overdraft-only, Gold Loan offers both, everything else is Term Loan-only. */
|
||||
get newAppProductTypeOptions(): ProductType[] {
|
||||
return allowedProductTypes(this.newApp.productCode);
|
||||
}
|
||||
|
||||
/** The products saved in the database for the chosen Product Type and Sub Type. */
|
||||
get productOptions(): any[] {
|
||||
return this.dbProducts.filter(
|
||||
(p) =>
|
||||
p.advanceExtensions?.productTypeGroup === this.newApp.productGroup &&
|
||||
p.advanceExtensions?.productTypeSubGroup === this.newApp.productSubType
|
||||
);
|
||||
}
|
||||
|
||||
/** Only the tenures the chosen product allows (its min to max, in months). */
|
||||
get tenureOptions(): number[] {
|
||||
const p = this.selectedProduct;
|
||||
if (!p) {
|
||||
return [];
|
||||
}
|
||||
return Array.from({ length: Math.max(0, p.maxTenure - p.minTenure + 1) }, (_, i) => p.minTenure + i);
|
||||
}
|
||||
|
||||
get selectedProduct() {
|
||||
return this.products.find((p) => p.code === this.newApp.productCode) || null;
|
||||
}
|
||||
|
||||
onProductGroupChange(): void {
|
||||
this.newApp.productSubType = '';
|
||||
this.newApp.productCode = '';
|
||||
}
|
||||
|
||||
onSubTypeChange(): void {
|
||||
const options = this.productOptions;
|
||||
this.newApp.productCode = options.length === 1 ? options[0].shortName : '';
|
||||
this.onProductChange();
|
||||
}
|
||||
|
||||
/** Advances have no EMI (revolving limit); Loans are EMI term loans. */
|
||||
/** Switching Product can change which Product Types are even valid -- reset to that product's
|
||||
* default rather than leaving a stale selection (e.g. "Overdraft" left over from Gold Loan)
|
||||
* on a product that doesn't offer it. */
|
||||
onProductChange(): void {
|
||||
this.newApp.productType = this.newApp.productGroup === 'advances' ? 'OVERDRAFT' : 'TERM_LOAN';
|
||||
const product = this.selectedProduct;
|
||||
this.newApp.tenureMonths = product ? product.minTenure : null;
|
||||
this.newApp.productType = defaultProductType(this.newApp.productCode);
|
||||
}
|
||||
|
||||
/** Existing-customer search (real Fineract clients, unlike the rest of this mock pipeline). */
|
||||
@@ -231,7 +189,6 @@ export class LoanPipelineComponent implements OnInit {
|
||||
private readonly auth = inject(AuthenticationService);
|
||||
private readonly clientsService = inject(ClientsService);
|
||||
private readonly originationService = inject(LoanOriginationService);
|
||||
private readonly catalog = inject(LoanProductCatalogService);
|
||||
|
||||
/** The live book, loaded from the database. Every count, report and filter on this screen derives
|
||||
* from this one array, so the list can never disagree with what an application actually says. */
|
||||
@@ -264,27 +221,17 @@ export class LoanPipelineComponent implements OnInit {
|
||||
const creds = this.auth.getCredentials();
|
||||
this.isHeadOffice = this.auth.isHeadOfficeUser();
|
||||
this.currentOfficeName = creds?.officeName || 'Branch Office';
|
||||
this.ownBranchCode = creds?.officeId ? officeBranchCode(creds.officeId) : null;
|
||||
// The logged-in user's own office is the default branch. A branch-level login can only originate for its own branch:
|
||||
// the select is disabled and `saveNewApplication` forces the code again, so it is locked here and not just hidden.
|
||||
this.branches = this.ownBranchCode ? [{ code: this.ownBranchCode, name: this.currentOfficeName }] : [];
|
||||
this.branchFilter = this.isHeadOffice ? '' : this.ownBranchCode || '';
|
||||
this.newApp.branchCode = this.ownBranchCode || '';
|
||||
this.catalog.activeProducts().subscribe((products) => {
|
||||
this.dbProducts = products.filter((p) => p.shortName && p.advanceExtensions?.productTypeGroup);
|
||||
this.dbProducts.forEach((p) => registerOriginationProduct(p));
|
||||
this.loadingProducts = false;
|
||||
});
|
||||
this.originationService.listBranches().subscribe({
|
||||
next: (offices) => {
|
||||
if (offices.length > 0) {
|
||||
this.branches = offices;
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
/* keep the user's own office as the only choice */
|
||||
}
|
||||
});
|
||||
if (this.isHeadOffice) {
|
||||
this.branchFilter = '';
|
||||
} else {
|
||||
this.ownBranchCode =
|
||||
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase()))
|
||||
?.code || null;
|
||||
this.branchFilter = this.ownBranchCode || this.branches[0].code;
|
||||
// A branch-level login can only originate applications for its own branch — locked here,
|
||||
// not just hidden, since the New Application form posts whatever branchCode is bound.
|
||||
this.newApp.branchCode = this.branchFilter;
|
||||
}
|
||||
|
||||
this.loadApplications();
|
||||
}
|
||||
@@ -363,42 +310,6 @@ export class LoanPipelineComponent implements OnInit {
|
||||
return this.branchFilter ? this.applications.filter((a) => a.branchCode === this.branchFilter) : this.applications;
|
||||
}
|
||||
|
||||
private get currentUserName(): string {
|
||||
return (this.auth.getCredentials()?.username ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
/** Applications with an approval sent and waiting for a decision (maker-checker), newest first. */
|
||||
get awaitingApproval() {
|
||||
const rows: {
|
||||
app: LoanApplication;
|
||||
stage: string;
|
||||
sentBy: string;
|
||||
sentOn: string;
|
||||
mine: boolean;
|
||||
exception: boolean;
|
||||
}[] = [];
|
||||
this.applications.forEach((app) => {
|
||||
Object.entries(app.approvalRequests ?? {}).forEach(
|
||||
([
|
||||
,
|
||||
request
|
||||
]) => {
|
||||
if (request && request.status === 'PENDING') {
|
||||
rows.push({
|
||||
app,
|
||||
stage: STAGE_LABELS[app.currentStage],
|
||||
sentBy: request.submittedBy,
|
||||
sentOn: request.submittedOn,
|
||||
mine: (request.submittedBy ?? '').toLowerCase() === this.currentUserName,
|
||||
exception: request.requestedDecision === 'EXCEPTION_APPROVED'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
get filteredApplications(): LoanApplication[] {
|
||||
return this.scopedApplications.filter((a) => {
|
||||
if (this.channelFilter && a.channel !== this.channelFilter) {
|
||||
@@ -632,7 +543,7 @@ export class LoanPipelineComponent implements OnInit {
|
||||
|
||||
// -------------------- New Application intake --------------------
|
||||
productOf(code: string) {
|
||||
return this.products.find((p) => p.code === code);
|
||||
return this.products.find((p) => p.code === code) || this.products[0];
|
||||
}
|
||||
|
||||
/** Switching Customer Type away from "Existing" drops any client picked under the old mode --
|
||||
@@ -697,27 +608,8 @@ export class LoanPipelineComponent implements OnInit {
|
||||
if (!this.isHeadOffice && this.ownBranchCode) {
|
||||
this.newApp.branchCode = this.ownBranchCode;
|
||||
}
|
||||
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) ||
|
||||
this.branches[0] || { code: this.newApp.branchCode, name: this.currentOfficeName };
|
||||
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0];
|
||||
const product = this.productOf(this.newApp.productCode);
|
||||
if (!product) {
|
||||
this.toast('Select the Product Type, Sub Type and Product.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.newApp.requestedAmount &&
|
||||
(this.newApp.requestedAmount < product.minAmt || this.newApp.requestedAmount > product.maxAmt)
|
||||
) {
|
||||
this.toast(
|
||||
`Amount must be between ₹${product.minAmt.toLocaleString('en-IN')} and ₹${product.maxAmt.toLocaleString('en-IN')} for ${product.name}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const tenure = this.newApp.tenureMonths || product.minTenure;
|
||||
if (tenure < product.minTenure || tenure > product.maxTenure) {
|
||||
this.toast(`Tenure must be between ${product.minTenure} and ${product.maxTenure} months for ${product.name}.`);
|
||||
return;
|
||||
}
|
||||
const seq = this.applications.length + 20000;
|
||||
const app = createApplication(
|
||||
{
|
||||
@@ -752,11 +644,9 @@ export class LoanPipelineComponent implements OnInit {
|
||||
customerType: 'NEW',
|
||||
applicantName: '',
|
||||
clientId: null,
|
||||
branchCode: this.ownBranchCode || this.branches[0]?.code || '',
|
||||
productGroup: '',
|
||||
productSubType: '',
|
||||
productCode: '',
|
||||
productType: 'TERM_LOAN',
|
||||
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code,
|
||||
productCode: this.products[0].code,
|
||||
productType: defaultProductType(this.products[0].code),
|
||||
requestedAmount: null,
|
||||
tenureMonths: null
|
||||
};
|
||||
|
||||
@@ -6,53 +6,13 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cla-container">
|
||||
<!-- Hero header, same as the deposit account screens -->
|
||||
<div class="cla-header">
|
||||
<fa-icon icon="university" class="cla-header__icon"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cla-header__title">Create Loan Account</h2>
|
||||
<div class="cla-header__sub">
|
||||
{{
|
||||
viewMode
|
||||
? 'The account created for this application, as it was filled (view only).'
|
||||
: 'Pick a product, set terms and review before submit.'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (submitBlocked) {
|
||||
<div class="cla-existing">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Not submitted</strong>
|
||||
<span>{{ submitBlocked }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (existingLosLoan) {
|
||||
<div class="cla-existing">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Loan account {{ existingLosLoan.accountNo }} — {{ existingLosLoan.status }}</strong>
|
||||
<span>{{
|
||||
viewMode
|
||||
? 'You are viewing the account that was created. Nothing here can be changed.'
|
||||
: 'It is already created for this application. A second account cannot be created.'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="container">
|
||||
<mat-stepper
|
||||
orientation="horizontal"
|
||||
orientation="vertical"
|
||||
[linear]="false"
|
||||
class="cla-stepper"
|
||||
labelPosition="end"
|
||||
class="mat-elevation-z8"
|
||||
labelPosition="bottom"
|
||||
#loansAccountStepper
|
||||
(selectionChange)="refreshLosSections()"
|
||||
>
|
||||
<ng-template matStepperIcon="number">
|
||||
<fa-icon icon="pencil-alt" size="sm"></fa-icon>
|
||||
@@ -75,10 +35,7 @@
|
||||
</ng-template>
|
||||
|
||||
<mat-step [stepControl]="loansAccountDetailsForm">
|
||||
<ng-template matStepLabel
|
||||
><fa-icon icon="check-square" class="cla-tab__icon"></fa-icon
|
||||
>{{ 'labels.inputs.DETAILS' | translate }}</ng-template
|
||||
>
|
||||
<ng-template matStepLabel>{{ 'labels.inputs.DETAILS' | translate }}</ng-template>
|
||||
|
||||
<mifosx-loans-account-details-step
|
||||
[loansAccountTemplate]="loansAccountTemplate"
|
||||
@@ -89,9 +46,7 @@
|
||||
</mat-step>
|
||||
|
||||
<mat-step [stepControl]="loansAccountTermsForm">
|
||||
<ng-template matStepLabel
|
||||
><fa-icon icon="file-alt" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.TERMS' | translate }}</ng-template
|
||||
>
|
||||
<ng-template matStepLabel>{{ 'labels.inputs.TERMS' | translate }}</ng-template>
|
||||
|
||||
<mifosx-loans-account-terms-step
|
||||
#loanAccountTerms
|
||||
@@ -100,103 +55,12 @@
|
||||
[collateralOptions]="collateralOptions"
|
||||
[loansAccountFormValid]="loansAccountFormValid"
|
||||
[loanPrincipal]="loanPrincipal"
|
||||
[losApplication]="loansAccountDetailsStep?.losApplication"
|
||||
[losAdvance]="
|
||||
!!loansAccountDetailsStep?.losApplication && loansAccountDetailsStep?.categoryFilter === 'advances'
|
||||
"
|
||||
>
|
||||
</mifosx-loans-account-terms-step>
|
||||
</mat-step>
|
||||
|
||||
<!-- Opened from Loan Origination for an entity (not a person): its related persons, the authorised signatories. -->
|
||||
@if (loansAccountDetailsStep?.losIsNonIndividual) {
|
||||
<mat-step>
|
||||
<ng-template matStepLabel
|
||||
><fa-icon icon="university" class="cla-tab__icon"></fa-icon>Related Persons</ng-template
|
||||
>
|
||||
<table class="cla-related">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client ID</th>
|
||||
<th>Title</th>
|
||||
<th>Name</th>
|
||||
<th>Pin</th>
|
||||
<th>Birth date</th>
|
||||
<th>Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (person of loansAccountDetailsStep.losRelatedPersons; track $index) {
|
||||
<tr>
|
||||
<td>{{ person.clientId ?? '—' }}</td>
|
||||
<td>{{ person.title || '—' }}</td>
|
||||
<td>{{ person.loading ? 'Loading…' : person.name || '—' }}</td>
|
||||
<td>{{ person.pincode || '—' }}</td>
|
||||
<td>{{ person.birthDate ? (person.birthDate | date: 'dd-MM-yyyy') : '—' }}</td>
|
||||
<td>{{ person.address || '—' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6">No authorised signatories are recorded for this entity.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
|
||||
<button mat-raised-button matStepperPrevious>
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Previous' | translate }}
|
||||
</button>
|
||||
<button mat-raised-button matStepperNext>
|
||||
{{ 'labels.buttons.Next' | translate }}
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-step>
|
||||
}
|
||||
|
||||
<!-- Advances opened from Loan Origination: the limit, its security and the purpose and renewal date the officer gives. -->
|
||||
@if (loansAccountDetailsStep?.advancesSelected) {
|
||||
<mat-step>
|
||||
<ng-template matStepLabel
|
||||
><fa-icon icon="check-square" class="cla-tab__icon"></fa-icon>Limit & Security</ng-template
|
||||
>
|
||||
<mifosx-loans-account-los-limit-step
|
||||
[losApplication]="loansAccountDetailsStep?.losApplication"
|
||||
[productId]="loansAccountDetailsStep?.loansAccountDetailsForm?.get('productId')?.value"
|
||||
>
|
||||
</mifosx-loans-account-los-limit-step>
|
||||
</mat-step>
|
||||
}
|
||||
|
||||
<!-- Advances opened from Loan Origination: the SMS alerts the customer gets (the account master's SMS Alert page). -->
|
||||
@if (loansAccountDetailsStep?.advancesSelected) {
|
||||
<mat-step>
|
||||
<ng-template matStepLabel><fa-icon icon="bell" class="cla-tab__icon"></fa-icon>SMS Alerts</ng-template>
|
||||
<mifosx-loans-account-los-sms-step [chequeBookOffered]="!!loansAccountTermsStep?.chequeBookOffered">
|
||||
</mifosx-loans-account-los-sms-step>
|
||||
</mat-step>
|
||||
}
|
||||
|
||||
<!-- The guarantors named at Loan Origination (the account master's guarantor page). -->
|
||||
<!-- Not for a loan on a deposit (FD / RD / DDS / LIC): the deposit is the security, no guarantor is taken. -->
|
||||
@if (
|
||||
loansAccountDetailsStep?.losApplication &&
|
||||
!loansAccountDetailsStep?.losApplication?.depositSelection &&
|
||||
(loansAccountDetailsStep?.losApplication?.guarantorsRequiredCount ||
|
||||
loansAccountDetailsStep?.losApplication?.guarantors?.length)
|
||||
) {
|
||||
<mat-step>
|
||||
<ng-template matStepLabel><fa-icon icon="user-shield" class="cla-tab__icon"></fa-icon>Guarantors</ng-template>
|
||||
<mifosx-loans-account-los-guarantors-step [losApplication]="loansAccountDetailsStep?.losApplication">
|
||||
</mifosx-loans-account-los-guarantors-step>
|
||||
</mat-step>
|
||||
}
|
||||
|
||||
<mat-step>
|
||||
<ng-template matStepLabel
|
||||
><fa-icon icon="coins" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.CHARGES' | translate }}</ng-template
|
||||
>
|
||||
<ng-template matStepLabel>{{ 'labels.inputs.CHARGES' | translate }}</ng-template>
|
||||
|
||||
<mifosx-loans-account-charges-step
|
||||
#loanAccountCharges
|
||||
@@ -204,17 +68,13 @@
|
||||
[loansAccountTemplate]="loansAccountTemplate"
|
||||
[loansAccountFormValid]="loansAccountFormValid"
|
||||
[loansSavingsAccountLinked]="loansSavingsAccountLinked"
|
||||
[viewOnly]="viewMode"
|
||||
>
|
||||
</mifosx-loans-account-charges-step>
|
||||
</mat-step>
|
||||
|
||||
@if (loansAccountFormValid && !losAdvance) {
|
||||
@if (loansAccountFormValid) {
|
||||
<mat-step state="repayment" completed>
|
||||
<ng-template matStepLabel
|
||||
><fa-icon icon="university" class="cla-tab__icon"></fa-icon
|
||||
>{{ 'labels.inputs.REPAYMENT SCHEDULE' | translate }}</ng-template
|
||||
>
|
||||
<ng-template matStepLabel>{{ 'labels.inputs.REPAYMENT SCHEDULE' | translate }}</ng-template>
|
||||
<mifosx-loans-account-schedule-step
|
||||
#loanAccountSchedule
|
||||
[loansAccountTemplate]="loansAccountTemplate"
|
||||
@@ -228,25 +88,18 @@
|
||||
|
||||
@for (datatable of datatables; track datatable) {
|
||||
<mat-step>
|
||||
<ng-template matStepLabel
|
||||
><fa-icon icon="file-alt" class="cla-tab__icon"></fa-icon>{{ datatable.registeredTableName }}</ng-template
|
||||
>
|
||||
<ng-template matStepLabel>{{ datatable.registeredTableName }}</ng-template>
|
||||
<mifosx-loans-account-datatable-step [datatableData]="datatable" #dtloan></mifosx-loans-account-datatable-step>
|
||||
</mat-step>
|
||||
}
|
||||
|
||||
@if (loansAccountFormValid) {
|
||||
<mat-step state="preview" completed>
|
||||
<ng-template matStepLabel
|
||||
><fa-icon icon="eye" class="cla-tab__icon"></fa-icon>{{ 'labels.inputs.PREVIEW' | translate }}</ng-template
|
||||
>
|
||||
<ng-template matStepLabel>{{ 'labels.inputs.PREVIEW' | translate }}</ng-template>
|
||||
<mifosx-loans-account-preview-step
|
||||
[loansAccountTemplate]="loansAccountTemplate"
|
||||
[loansAccountProductTemplate]="loansAccountProductTemplate"
|
||||
[loansAccount]="loansAccount"
|
||||
[losSections]="losSections"
|
||||
[losMode]="!!loansAccountDetailsStep?.losApplication"
|
||||
[viewOnly]="viewMode"
|
||||
(submitEvent)="submit()"
|
||||
>
|
||||
</mifosx-loans-account-preview-step>
|
||||
|
||||
@@ -5,190 +5,3 @@
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/* The Nexora account-creation look (same as the deposit account screens): hero header, a tab strip for the steps, a footer
|
||||
with Previous / Next. The steps themselves still work as a stepper. */
|
||||
$navy: #0d1b2a;
|
||||
$blue: #1e88e5;
|
||||
$blue-d: #1e40af;
|
||||
$indigo: #4f46e5;
|
||||
$border: #e2e8f0;
|
||||
|
||||
.cla-container {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.cla-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
&__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, $blue 0%, $indigo 100%);
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: $navy;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12.5px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.cla-tab__icon {
|
||||
margin-right: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cla-stepper {
|
||||
background: #fff;
|
||||
border: 1px solid $border;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
|
||||
overflow: hidden;
|
||||
|
||||
::ng-deep {
|
||||
.mat-horizontal-stepper-header-container {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid $border;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Step headers read as tabs: label only, equal width, underline on the open one. */
|
||||
.mat-step-header {
|
||||
flex: 1;
|
||||
height: 50px;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.mat-step-header .mat-step-icon,
|
||||
.mat-stepper-horizontal-line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mat-step-header .mat-step-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.mat-step-header[aria-selected='true'] {
|
||||
border-bottom-color: $blue-d;
|
||||
|
||||
.mat-step-label {
|
||||
color: $blue-d;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-step-header:hover:not([aria-selected='true']) {
|
||||
background: rgb(30 64 175 / 5%);
|
||||
|
||||
.mat-step-label {
|
||||
color: $navy;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-horizontal-content-container {
|
||||
padding: 24px;
|
||||
|
||||
@media (width <= 768px) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Previous on the left, Next on the right, with a rule above, as on the deposit screens. */
|
||||
.stepper-buttons {
|
||||
justify-content: space-between;
|
||||
margin-top: 24px !important;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
|
||||
/* The shared row class clips what is taller than it; the 40px buttons must show whole. */
|
||||
height: auto !important;
|
||||
min-height: 56px;
|
||||
overflow: visible !important;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
[matStepperPrevious][disabled] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[matStepperNext] {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Related persons of an entity (its authorised signatories), as on the account master. */
|
||||
.cla-related {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* An account is already open for the Loan Origination application. */
|
||||
.cla-existing {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 12px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 13.5px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,7 @@
|
||||
*/
|
||||
|
||||
/** Angular Imports */
|
||||
import { AfterViewChecked, Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { catchError, map, switchMap } from 'rxjs/operators';
|
||||
import { Dates } from 'app/core/utils/dates';
|
||||
import { LoansAccountLosLimitStepComponent } from '../loans-account-stepper/loans-account-los-limit-step/loans-account-los-limit-step.component';
|
||||
import { LoansAccountLosGuarantorsStepComponent } from '../loans-account-stepper/loans-account-los-guarantors-step/loans-account-los-guarantors-step.component';
|
||||
import { LoansAccountLosSmsStepComponent } from '../loans-account-stepper/loans-account-los-sms-step/loans-account-los-sms-step.component';
|
||||
import { LosPreviewSection } from '../loans-account-stepper/los-preview-section';
|
||||
import { LoanAccountState, LoanOriginationService } from 'app/loan-origination/loan-origination.service';
|
||||
import { Component, QueryList, ViewChild, ViewChildren, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
||||
/** Custom Services */
|
||||
@@ -29,14 +20,7 @@ import { LoansAccountDetailsStepComponent } from '../loans-account-stepper/loans
|
||||
import { LoansAccountTermsStepComponent } from '../loans-account-stepper/loans-account-terms-step/loans-account-terms-step.component';
|
||||
import { LoansAccountChargesStepComponent } from '../loans-account-stepper/loans-account-charges-step/loans-account-charges-step.component';
|
||||
import { LoansAccountDatatableStepComponent } from '../loans-account-stepper/loans-account-datatable-step/loans-account-datatable-step.component';
|
||||
import {
|
||||
MatStepper,
|
||||
MatStepperIcon,
|
||||
MatStep,
|
||||
MatStepLabel,
|
||||
MatStepperNext,
|
||||
MatStepperPrevious
|
||||
} from '@angular/material/stepper';
|
||||
import { MatStepper, MatStepperIcon, MatStep, MatStepLabel } from '@angular/material/stepper';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { LoansAccountScheduleStepComponent } from '../loans-account-stepper/loans-account-schedule-step/loans-account-schedule-step.component';
|
||||
import { LoansAccountPreviewStepComponent } from '../loans-account-stepper/loans-account-preview-step/loans-account-preview-step.component';
|
||||
@@ -56,90 +40,25 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
FaIconComponent,
|
||||
MatStep,
|
||||
MatStepLabel,
|
||||
MatStepperNext,
|
||||
MatStepperPrevious,
|
||||
LoansAccountDetailsStepComponent,
|
||||
LoansAccountTermsStepComponent,
|
||||
LoansAccountLosLimitStepComponent,
|
||||
LoansAccountLosGuarantorsStepComponent,
|
||||
LoansAccountLosSmsStepComponent,
|
||||
LoansAccountChargesStepComponent,
|
||||
LoansAccountScheduleStepComponent,
|
||||
LoansAccountDatatableStepComponent,
|
||||
LoansAccountPreviewStepComponent
|
||||
]
|
||||
})
|
||||
export class CreateLoansAccountComponent implements AfterViewChecked {
|
||||
export class CreateLoansAccountComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private loansService = inject(LoansService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private clientService = inject(ClientsService);
|
||||
private http = inject(HttpClient);
|
||||
private dateUtils = inject(Dates);
|
||||
private originationService = inject(LoanOriginationService);
|
||||
|
||||
/** Imports all the step component */
|
||||
@ViewChild(LoansAccountDetailsStepComponent, { static: true })
|
||||
loansAccountDetailsStep: LoansAccountDetailsStepComponent;
|
||||
@ViewChild(LoansAccountTermsStepComponent, { static: true }) loansAccountTermsStep: LoansAccountTermsStepComponent;
|
||||
/** The Limit & Security step an Advances account opened from Loan Origination has. */
|
||||
@ViewChild(LoansAccountLosLimitStepComponent) loansAccountLosLimitStep: LoansAccountLosLimitStepComponent;
|
||||
@ViewChild(LoansAccountLosGuarantorsStepComponent)
|
||||
loansAccountLosGuarantorsStep: LoansAccountLosGuarantorsStepComponent;
|
||||
/** What the Loan Origination steps filled, listed in the Preview; rebuilt whenever the officer moves to another step. */
|
||||
losSections: LosPreviewSection[] = [];
|
||||
|
||||
/** An Advances account opened from Loan Origination has no repayment schedule. */
|
||||
get losAdvance(): boolean {
|
||||
return (
|
||||
!!this.loansAccountDetailsStep?.losApplication && this.loansAccountDetailsStep?.categoryFilter === 'advances'
|
||||
);
|
||||
}
|
||||
|
||||
refreshLosSections(): void {
|
||||
const details = this.loansAccountDetailsStep;
|
||||
if (!details?.losApplication) {
|
||||
// an Advances account opened without a Loan Origination application still lists the limit and SMS steps it filled
|
||||
this.losSections = details?.advancesSelected ? [
|
||||
...(this.loansAccountLosLimitStep?.previewSections() ?? []),
|
||||
...(this.loansAccountLosSmsStep?.previewSections() ?? [])
|
||||
] : [];
|
||||
return;
|
||||
}
|
||||
this.originationService.loanForApplication(details.losApplication.applicationNo).subscribe((loan) => {
|
||||
this.existingLosLoan = loan;
|
||||
});
|
||||
const persons = details.losIsNonIndividual
|
||||
? [
|
||||
{
|
||||
title: 'Related persons',
|
||||
rows: (details.losRelatedPersons ?? []).map((p: any, i: number) => ({
|
||||
label: 'Person ' + (i + 1),
|
||||
value: [
|
||||
p.name,
|
||||
p.title,
|
||||
p.pincode,
|
||||
p.address
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}))
|
||||
}
|
||||
]
|
||||
: [];
|
||||
this.losSections = [
|
||||
...details.previewSections(),
|
||||
...persons,
|
||||
...(this.loansAccountTermsStep?.previewSections() ?? []),
|
||||
...(this.loansAccountLosLimitStep?.previewSections() ?? []),
|
||||
...(this.loansAccountLosSmsStep?.previewSections() ?? []),
|
||||
...(this.loansAccountLosGuarantorsStep?.previewSections() ?? [])
|
||||
];
|
||||
}
|
||||
|
||||
/** The SMS Alerts step of an account opened from Loan Origination. */
|
||||
@ViewChild(LoansAccountLosSmsStepComponent) loansAccountLosSmsStep: LoansAccountLosSmsStepComponent;
|
||||
@ViewChild(LoansAccountChargesStepComponent, { static: true })
|
||||
loansAccountChargesStep: LoansAccountChargesStepComponent;
|
||||
/** Get handle on dtloan tags in the template */
|
||||
@@ -251,138 +170,6 @@ export class CreateLoansAccountComponent implements AfterViewChecked {
|
||||
* Submits Data to create loan account
|
||||
*/
|
||||
submit() {
|
||||
// An Advances account needs the Limit & Security step filled (purpose, and the sanction when there is no application).
|
||||
this.submitBlocked = '';
|
||||
const limit = this.loansAccountLosLimitStep;
|
||||
if (this.loansAccountDetailsStep?.advancesSelected && limit?.form.invalid) {
|
||||
limit.form.markAllAsTouched();
|
||||
this.submitBlocked =
|
||||
'Complete the Limit & Security step before submitting: the purpose of the loan and, without a Loan Origination application, the sanction no. and date.';
|
||||
return;
|
||||
}
|
||||
// An application that does not belong to this customer, or is not ready for an account, is never linked.
|
||||
if (this.loansAccountDetailsStep?.losMode && this.loansAccountDetailsStep.losError) {
|
||||
return;
|
||||
}
|
||||
const los = this.loansAccountDetailsStep?.losApplication;
|
||||
if (!los) {
|
||||
this.createAccount();
|
||||
return;
|
||||
}
|
||||
// One account per Loan Origination application: look again just before creating (it may have been opened meanwhile).
|
||||
this.originationService.loanForApplication(los.applicationNo).subscribe((existing) => {
|
||||
this.existingLosLoan = existing;
|
||||
if (!existing) {
|
||||
this.createAccount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Why Submit did nothing (shown above the steps); empty when nothing is wrong. */
|
||||
submitBlocked = '';
|
||||
|
||||
/** The account already opened for the Loan Origination application on screen (null: none yet). */
|
||||
existingLosLoan: LoanAccountState | null = null;
|
||||
|
||||
/** Opened with ?view=1 from Loan Origination (View Account): the account that was created, shown read-only. */
|
||||
get viewMode(): boolean {
|
||||
return this.route.snapshot.queryParamMap.get('view') === '1';
|
||||
}
|
||||
|
||||
private viewApplied = false;
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
const details = this.loansAccountDetailsStep;
|
||||
if (
|
||||
!this.viewMode ||
|
||||
this.viewApplied ||
|
||||
!details?.losApplication ||
|
||||
!this.loansAccountLosLimitStep ||
|
||||
!this.loansAccountLosSmsStep ||
|
||||
!this.loansAccountTermsStep
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.viewApplied = true;
|
||||
this.originationService.loanForApplication(details.losApplication.applicationNo).subscribe((loan) => {
|
||||
if (!loan) {
|
||||
return;
|
||||
}
|
||||
this.existingLosLoan = loan;
|
||||
forkJoin({
|
||||
full: this.http.get<any>(`/loans/${loan.id}?associations=charges`),
|
||||
services: this.http.get<any[]>(`/datatables/loan_account_services/${loan.id}`).pipe(catchError(() => of([])))
|
||||
}).subscribe(({ full, services }) => this.showCreatedAccount(full, services?.[0] ?? {}));
|
||||
});
|
||||
}
|
||||
|
||||
/** Fills every step from what was saved with the account, and locks them. */
|
||||
private showCreatedAccount(loan: any, row: any): void {
|
||||
const yes = (v: any) => v === true || v === 'true';
|
||||
const iso = (v: any) =>
|
||||
Array.isArray(v)
|
||||
? v
|
||||
.slice(0, 3)
|
||||
.map((n: number) => String(n).padStart(2, '0'))
|
||||
.join('-')
|
||||
.replace(/^(\d+)-/, '$1-')
|
||||
: '';
|
||||
const num = (v: any) => (v === null || v === undefined ? null : Number(v));
|
||||
|
||||
const limit = this.loansAccountLosLimitStep;
|
||||
limit.form.patchValue({
|
||||
purpose: row.loan_purpose_text ?? loan.loanPurposeName ?? '',
|
||||
documentRenewalDate: iso(row.document_renewal_date),
|
||||
prioritySectorCategory: row.priority_sector_category ?? ''
|
||||
});
|
||||
limit.form.disable();
|
||||
|
||||
const sms = this.loansAccountLosSmsStep;
|
||||
sms.form.patchValue({
|
||||
smsActive: yes(row.sms_active) ? 'yes' : 'no',
|
||||
smsCharge: yes(row.sms_charge) ? 'yes' : 'no',
|
||||
disbursementAlert: yes(row.sms_disbursement_alert),
|
||||
dueAlert: yes(row.sms_due_alert),
|
||||
overdueAlert: yes(row.sms_overdue_alert),
|
||||
renewAlert: yes(row.sms_renew_alert),
|
||||
closureAlert: yes(row.sms_closure_alert),
|
||||
repaymentAlert: yes(row.sms_credit_clearing),
|
||||
debitAlert: yes(row.sms_debit_clearing),
|
||||
chargeInterest: yes(row.sms_charge_interest),
|
||||
chequeBook: yes(row.sms_cheque_book),
|
||||
debitAbove: num(row.sms_debit_above) ?? 0,
|
||||
creditAbove: num(row.sms_credit_above) ?? 0
|
||||
});
|
||||
sms.form.disable();
|
||||
|
||||
const terms = this.loansAccountTermsStep;
|
||||
terms.losServicesForm.patchValue({
|
||||
chequeBookIssue: yes(row.cheque_book_issue),
|
||||
ppsLimit: num(row.pps_limit),
|
||||
atmActive: yes(row.atm_active),
|
||||
impsActive: yes(row.imps_active),
|
||||
impsPrimary: yes(row.imps_primary_account),
|
||||
mmid: row.mmid ?? '',
|
||||
upiActive: yes(row.upi_active),
|
||||
posActive: yes(row.pos_active),
|
||||
atmPerTxn: num(row.atm_per_txn_limit),
|
||||
atmDay: num(row.atm_day_limit),
|
||||
posPerTxn: num(row.pos_per_txn_limit),
|
||||
posDay: num(row.pos_day_limit),
|
||||
upiPerTxn: num(row.upi_per_txn_limit),
|
||||
upiDay: num(row.upi_day_limit)
|
||||
});
|
||||
terms.losServicesForm.disable();
|
||||
|
||||
// the charges the account actually has
|
||||
this.loansAccountChargesStep.chargesDataSource = (loan.charges ?? []).map((c: any) => ({
|
||||
...c,
|
||||
chargeId: c.chargeId ?? c.id
|
||||
}));
|
||||
this.refreshLosSections();
|
||||
}
|
||||
|
||||
private createAccount() {
|
||||
const locale = this.settingsService.language.code;
|
||||
const dateFormat = this.settingsService.dateFormat;
|
||||
const payload = this.loansService.buildLoanRequestPayload(
|
||||
@@ -392,10 +179,6 @@ export class CreateLoansAccountComponent implements AfterViewChecked {
|
||||
locale,
|
||||
dateFormat
|
||||
);
|
||||
// The application number is the loan's external id: it is how Loan Origination finds (and shows the status of) this account.
|
||||
if (this.loansAccountDetailsStep?.losApplication) {
|
||||
payload['externalId'] = this.loansAccountDetailsStep.losApplication.applicationNo;
|
||||
}
|
||||
|
||||
if (this.loansAccountProductTemplate.datatables && this.loansAccountProductTemplate.datatables.length > 0) {
|
||||
const datatables: any[] = [];
|
||||
@@ -405,84 +188,15 @@ export class CreateLoansAccountComponent implements AfterViewChecked {
|
||||
payload['datatables'] = datatables;
|
||||
}
|
||||
|
||||
// Opened from Loan Origination for an Advances account: the services the officer filled are saved with the new loan.
|
||||
const losServices =
|
||||
this.loansAccountDetailsStep.losMode && this.loansAccountTermsStep.losAdvance
|
||||
? {
|
||||
...this.loansAccountTermsStep.losServices,
|
||||
// sanction no. and date, secured, security type and the document renewal date, with the services
|
||||
...(this.loansAccountLosLimitStep?.datatableValues((iso) =>
|
||||
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
|
||||
) ?? {}),
|
||||
...(this.loansAccountLosSmsStep?.datatableValues((iso) =>
|
||||
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
|
||||
) ?? {})
|
||||
}
|
||||
: this.loansAccountDetailsStep.advancesSelected
|
||||
? {
|
||||
// no application: only what the Limit & Security and SMS Alerts steps took
|
||||
...(this.loansAccountLosLimitStep?.datatableValues((iso) =>
|
||||
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
|
||||
) ?? {}),
|
||||
...(this.loansAccountLosSmsStep?.datatableValues((iso) =>
|
||||
this.dateUtils.formatDate(new Date(iso + 'T00:00:00'), dateFormat)
|
||||
) ?? {})
|
||||
}
|
||||
: null;
|
||||
|
||||
const create = () =>
|
||||
this.loansService.createLoansAccount(payload).subscribe((response: any) => {
|
||||
if (losServices && response.loanId) {
|
||||
const body = { ...losServices, locale: locale, dateFormat: dateFormat };
|
||||
this.http.post(`/datatables/loan_account_services/${response.loanId}`, body).subscribe();
|
||||
}
|
||||
this.router.navigate(
|
||||
[
|
||||
'../',
|
||||
response.resourceId,
|
||||
'general'
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
});
|
||||
|
||||
// The purpose is typed, but the loan's own purpose field (shown in the approval list and on the loan) takes a code value:
|
||||
// use the matching one, or add it to the Loan Purpose code.
|
||||
const purpose = this.loansAccountLosLimitStep?.purposeText;
|
||||
if (purpose) {
|
||||
this.purposeCodeValueId(purpose).subscribe((id) => {
|
||||
if (id) {
|
||||
payload['loanPurposeId'] = id;
|
||||
}
|
||||
create();
|
||||
});
|
||||
} else {
|
||||
create();
|
||||
}
|
||||
}
|
||||
|
||||
/** The Loan Purpose code value for this text: an existing one (by name or description), else a new one. */
|
||||
private purposeCodeValueId(text: string): Observable<number | null> {
|
||||
const wanted = text.trim().toLowerCase();
|
||||
return this.http.get<any[]>('/codes/3/codevalues').pipe(
|
||||
switchMap((values) => {
|
||||
const found = (values ?? []).find(
|
||||
(v) => v.name?.trim().toLowerCase() === wanted || v.description?.trim().toLowerCase() === wanted
|
||||
);
|
||||
if (found) {
|
||||
return of(found.id as number);
|
||||
}
|
||||
const position = (values ?? []).reduce((max, v) => Math.max(max, v.position ?? 0), 0) + 1;
|
||||
return this.http
|
||||
.post<any>('/codes/3/codevalues', {
|
||||
name: text.trim().slice(0, 100),
|
||||
description: text.trim(),
|
||||
isActive: true,
|
||||
position
|
||||
})
|
||||
.pipe(map((r) => r.subResourceId as number));
|
||||
}),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
this.loansService.createLoansAccount(payload).subscribe((response: any) => {
|
||||
this.router.navigate(
|
||||
[
|
||||
'../',
|
||||
response.resourceId,
|
||||
'general'
|
||||
],
|
||||
{ relativeTo: this.route }
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+20
-26
@@ -6,26 +6,24 @@
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="layout-row-wrap gap-2px responsive-column" [class.view-only]="viewOnly">
|
||||
@if (!viewOnly) {
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
|
||||
<mat-select #charge>
|
||||
@for (charge of chargeData; track charge) {
|
||||
<mat-option [value]="charge">
|
||||
{{ charge.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<div class="layout-row-wrap gap-2px responsive-column">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.Charge' | translate }}</mat-label>
|
||||
<mat-select #charge>
|
||||
@for (charge of chargeData; track charge) {
|
||||
<mat-option [value]="charge">
|
||||
{{ charge.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="flex-48 align-center">
|
||||
<button type="button" mat-raised-button color="primary" (click)="addCharge(charge)" [disabled]="!charge.value">
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="flex-48 align-center">
|
||||
<button type="button" mat-raised-button color="primary" (click)="addCharge(charge)" [disabled]="!charge.value">
|
||||
<fa-icon icon="plus" class="m-r-10"></fa-icon>
|
||||
{{ 'labels.buttons.Add' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table
|
||||
class="flex-98 mat-elevation-z1"
|
||||
@@ -104,13 +102,9 @@
|
||||
<ng-container matColumnDef="action">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let charge">
|
||||
@if (canDelete(charge)) {
|
||||
<button mat-icon-button color="warn" (click)="deleteCharge(charge)">
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
</button>
|
||||
} @else {
|
||||
<span class="charge-locked" title="Comes with the product">Product</span>
|
||||
}
|
||||
<button mat-icon-button color="warn" (click)="deleteCharge(charge)">
|
||||
<fa-icon icon="trash"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
||||
-16
@@ -42,19 +42,3 @@ mat-divider {
|
||||
.tableName {
|
||||
padding-left: 2%;
|
||||
}
|
||||
|
||||
/* A charge the product brings: shown, but not removable. */
|
||||
.charge-locked {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* An account already created: the charges are shown, not edited. */
|
||||
.view-only [mat-icon-button] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+2
-20
@@ -147,9 +147,6 @@ export class LoansAccountChargesStepComponent implements OnInit, OnChanges {
|
||||
this.loanId = this.route.snapshot.params['loanId'];
|
||||
}
|
||||
|
||||
/** Shows the charges of an account already created (opened from Loan Origination): nothing can be added, changed or removed. */
|
||||
@Input() viewOnly = false;
|
||||
|
||||
ngOnInit() {
|
||||
if (this.loansAccountTemplate && this.loansAccountTemplate.charges) {
|
||||
this.chargesDataSource =
|
||||
@@ -213,16 +210,9 @@ export class LoansAccountChargesStepComponent implements OnInit, OnChanges {
|
||||
* Add a charge
|
||||
*/
|
||||
addCharge(charge: any) {
|
||||
const chargeId = charge.value.id || charge.value.chargeId;
|
||||
// A charge the product already brings stays as it is (and cannot be removed); adding it again would replace it.
|
||||
if (this.chargesDataSource.some((c: any) => (c.chargeId ?? c.id) === chargeId)) {
|
||||
charge.value = '';
|
||||
return;
|
||||
}
|
||||
const newCharge = {
|
||||
...charge.value,
|
||||
chargeId,
|
||||
addedAtCreation: true
|
||||
chargeId: charge.value.id || charge.value.chargeId
|
||||
};
|
||||
this.chargesDataSource = this.chargesDataSource.concat([newCharge]);
|
||||
charge.value = '';
|
||||
@@ -366,20 +356,12 @@ export class LoansAccountChargesStepComponent implements OnInit, OnChanges {
|
||||
get loansAccountCharges() {
|
||||
const uniqueCharges = this.getUniqueCharges(this.chargesDataSource);
|
||||
return {
|
||||
charges: uniqueCharges.map(({ addedAtCreation, ...charge }: any) => ({
|
||||
charges: uniqueCharges.map((charge: any) => ({
|
||||
...charge,
|
||||
chargeId: charge.chargeId ?? charge.id
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A charge the officer added while opening the account can be removed again; the ones the product brings cannot. (On an
|
||||
* existing account being modified every charge keeps its delete button.)
|
||||
*/
|
||||
canDelete(charge: any): boolean {
|
||||
return !this.viewOnly && (this.loanId != null || !!charge.addedAtCreation);
|
||||
}
|
||||
private getUniqueCharges<T extends { id?: number | string; chargeId?: number | string }>(charges: T[]): T[] {
|
||||
const uniqueChargesMap = new Map<number | string, T>();
|
||||
|
||||
|
||||
+26
-209
@@ -7,218 +7,35 @@
|
||||
-->
|
||||
|
||||
<form [formGroup]="loansAccountDetailsForm">
|
||||
@if (losMode) {
|
||||
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Category</mat-label>
|
||||
<input matInput [value]="losCategoryLabel" readonly />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Product type</mat-label>
|
||||
<input matInput [value]="losTypeLabel" readonly />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="layout-row-wrap gap-2px responsive-column">
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
||||
<input matInput [value]="losProductName" readonly />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
@if (!losMode) {
|
||||
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Category</mat-label>
|
||||
<mat-select
|
||||
[value]="categoryFilter"
|
||||
[disabled]="losMode"
|
||||
(selectionChange)="categoryFilter = $event.value; onCategoryChange()"
|
||||
>
|
||||
<mat-option value="">All categories</mat-option>
|
||||
@for (category of categories; track category.id) {
|
||||
<mat-option [value]="category.id">{{ category.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Product type</mat-label>
|
||||
<mat-select
|
||||
[value]="typeFilter"
|
||||
[disabled]="losMode"
|
||||
(selectionChange)="typeFilter = $event.value; applyProductFilters()"
|
||||
>
|
||||
<mat-option value="">All types</mat-option>
|
||||
@for (type of typeOptions; track type.id) {
|
||||
<mat-option [value]="type.id">{{ type.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="layout-row-wrap gap-2px responsive-column">
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
||||
<mat-select
|
||||
required
|
||||
matTooltip="{{ 'tooltips.Name of the loan product' | translate }}"
|
||||
formControlName="productId"
|
||||
>
|
||||
<mat-option>
|
||||
<ngx-mat-select-search
|
||||
placeholderLabel="{{ placeHolderLabel }}"
|
||||
noEntriesFoundLabel="{{ noEntriesFoundLabel }}"
|
||||
[formControl]="filterFormCtrl"
|
||||
></ngx-mat-select-search>
|
||||
<div class="layout-row-wrap gap-2px responsive-column">
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
||||
<mat-select
|
||||
required
|
||||
matTooltip="{{ 'tooltips.Name of the loan product' | translate }}"
|
||||
formControlName="productId"
|
||||
>
|
||||
<mat-option>
|
||||
<ngx-mat-select-search
|
||||
placeholderLabel="{{ placeHolderLabel }}"
|
||||
noEntriesFoundLabel="{{ noEntriesFoundLabel }}"
|
||||
[formControl]="filterFormCtrl"
|
||||
></ngx-mat-select-search>
|
||||
</mat-option>
|
||||
@for (product of productData | async; track product) {
|
||||
<mat-option [value]="product.id">
|
||||
{{ product.name }}
|
||||
</mat-option>
|
||||
@for (product of productData | async; track product) {
|
||||
<mat-option [value]="product.id">
|
||||
{{ product.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- The customer's Loan Origination applications that are ready for an account, narrowed by the category, type and product above. -->
|
||||
<div class="layout-row-wrap gap-2px responsive-column">
|
||||
<mat-form-field class="flex-fill">
|
||||
<mat-label>Link to a Loan Origination application</mat-label>
|
||||
<mat-select [disabled]="!losChoices.length" (selectionChange)="useLosApplication($event.value)">
|
||||
@for (choice of losChoices; track choice.app.applicationNo) {
|
||||
<mat-option [value]="choice.app.applicationNo">{{ choice.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-hint>
|
||||
@if (losChoices.length) {
|
||||
Choosing one opens this screen for that application, with its product, customer and terms filled in.
|
||||
} @else if (hasLosCandidates) {
|
||||
No application at Customer Signing matches this category, type and product for this customer.
|
||||
} @else {
|
||||
This customer has no Loan Origination application at Customer Signing waiting for an account.
|
||||
}
|
||||
</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (losMode) {
|
||||
@if (losError) {
|
||||
<p class="los-error">{{ losError }}</p>
|
||||
}
|
||||
@if (losApplication; as app) {
|
||||
<div class="los-card">
|
||||
<div class="los-card__head">
|
||||
<strong>Loan Origination application</strong>
|
||||
<a class="los-card__link" [routerLink]="['/loan-origination/application', app.applicationNo]">{{
|
||||
app.applicationNo
|
||||
}}</a>
|
||||
<span class="los-card__chip">{{ app.currentStage }}</span>
|
||||
</div>
|
||||
<div class="los-card__grid">
|
||||
<div>
|
||||
<span class="los-card__label">Amount</span><span>₹{{ app.requestedAmount | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Tenure</span><span>{{ app.tenureMonths }} months</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Interest rate</span><span>{{ app.interestRate }}% p.a.</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Secured on</span
|
||||
><span>{{ app.depositSelection?.accountNos?.join(', ') || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (losCustomer; as c) {
|
||||
<div class="los-customer">
|
||||
<h3 class="mat-h3">Customer details</h3>
|
||||
@if (c.missing) {
|
||||
<p class="los-error">This application is not linked to a bank client yet.</p>
|
||||
} @else {
|
||||
<div class="los-customer__grid">
|
||||
<div>
|
||||
<span class="los-card__label">Client ID</span><span>{{ c.accountNo || c.clientId }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Name</span><span>{{ c.name }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Client type</span><span>{{ c.clientType || '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Father's name</span><span>{{ c.fatherName || '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Gender</span><span>{{ c.gender || '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Date of birth</span><span>{{ c.dateOfBirth | date: 'dd-MM-yyyy' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Adult / Minor</span><span>{{ c.minor ? 'Minor' : 'Adult' }}</span>
|
||||
</div>
|
||||
<div class="los-customer__wide">
|
||||
<span class="los-card__label">Address</span>
|
||||
<span>
|
||||
@if (c.address; as ad) {
|
||||
{{ ad.lines.join(', ') }}{{ ad.city ? ', ' + ad.city : '' }}{{ ad.district ? ', ' + ad.district : ''
|
||||
}}{{ ad.state ? ', ' + ad.state : '' }}{{ ad.pincode ? ' - ' + ad.pincode : '' }}
|
||||
} @else {
|
||||
—
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Mobile</span><span>{{ c.mobile || '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Occupation</span><span>{{ c.occupation || '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Nationality</span><span>{{ c.nationality || '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Branch</span><span>{{ c.branch || '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">PAN</span
|
||||
><span
|
||||
>{{ c.pan || '—' }}
|
||||
@if (c.pan) {
|
||||
({{ c.panVerified ? 'verified' : 'not verified' }})
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Aadhaar</span
|
||||
><span
|
||||
>{{ c.aadhaar || '—' }}
|
||||
@if (c.aadhaar) {
|
||||
({{ c.aadhaarVerified ? 'verified' : 'not verified' }})
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">KYC status</span><span>{{ c.kycStatus || '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="los-card__label">Risk</span><span>{{ c.risk || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>
|
||||
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
|
||||
<strong>{{ 'labels.commons.required' | translate }}</strong>
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
@if (loanProductSelected && !losMode) {
|
||||
@if (loanProductSelected) {
|
||||
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>{{ 'labels.inputs.External id' | translate }}</mat-label>
|
||||
|
||||
-52
@@ -30,55 +30,3 @@ mat-divider {
|
||||
.margin-t {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
/* Opened from Loan Origination: the application card and the customer's details. */
|
||||
.los-card,
|
||||
.los-customer {
|
||||
border: 1px solid #c9d7f2;
|
||||
border-radius: 8px;
|
||||
background: #f3f7ff;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.los-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.los-card__link {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.los-card__chip {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #e3ebff;
|
||||
}
|
||||
|
||||
.los-card__grid,
|
||||
.los-customer__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 10px 16px;
|
||||
}
|
||||
|
||||
.los-customer__wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.los-card__label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #5b6b8c;
|
||||
}
|
||||
|
||||
.los-error {
|
||||
color: #b3261e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
-385
@@ -35,12 +35,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { LoanProductCatalogService } from '../../services/loan-product-catalog.service';
|
||||
import { LoanProductTemplateCardComponent } from '../loan-product-template-card/loan-product-template-card.component';
|
||||
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from 'app/products/loan-products/loan-product-types';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { LoanOriginationService } from 'app/loan-origination/loan-origination.service';
|
||||
import { LoanApplication } from 'app/loan-origination/loan-origination-data';
|
||||
import { LosPreviewSection } from '../los-preview-section';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Loans Account Details Step
|
||||
@@ -70,96 +64,6 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
||||
private settingsService = inject(SettingsService);
|
||||
private commons = inject(Commons);
|
||||
private catalog = inject(LoanProductCatalogService);
|
||||
private clientsService = inject(ClientsService);
|
||||
private originationService = inject(LoanOriginationService);
|
||||
|
||||
/**
|
||||
* Set when this screen was opened from the Loan Origination pipeline (?los=<application no>): the category, type and product
|
||||
* come from that application and cannot be changed, only what a loan account needs is asked, and the customer's details are
|
||||
* shown from the client record. Opened any other way, the screen is exactly as it always was.
|
||||
*/
|
||||
losApplicationNo: string | null = null;
|
||||
losApplication: LoanApplication | null = null;
|
||||
|
||||
/**
|
||||
* The customer's Loan Origination applications an account can be opened for: at Customer Signing, still in progress and with no
|
||||
* account opened yet. They are matched to loan products by name, so the category and type come from the product, not from a list here.
|
||||
*/
|
||||
private losCandidates: { app: LoanApplication; productId: number; group: string; type: string; label: string }[] = [];
|
||||
/** The candidates that fit the category, type and product chosen above; refreshed whenever one of them changes. */
|
||||
losChoices: { app: LoanApplication; productId: number; label: string }[] = [];
|
||||
losError = '';
|
||||
losProductName = '';
|
||||
/** The customer's details for the Customer block (read from the client record, KYC and address). */
|
||||
losCustomer: any = null;
|
||||
|
||||
/** The application and the customer it was raised for, for the Preview. */
|
||||
previewSections(): LosPreviewSection[] {
|
||||
const app = this.losApplication;
|
||||
const c = this.losCustomer;
|
||||
if (!app) {
|
||||
return [];
|
||||
}
|
||||
const sections: LosPreviewSection[] = [
|
||||
{
|
||||
title: 'Loan Origination application',
|
||||
rows: [
|
||||
{ label: 'Application no.', value: app.applicationNo },
|
||||
{ label: 'Product', value: app.productName ?? '—' },
|
||||
{ label: 'Branch', value: app.branchName ?? '—' }
|
||||
]
|
||||
}
|
||||
];
|
||||
if (c && !c.missing) {
|
||||
const a = c.address;
|
||||
sections.push({
|
||||
title: 'Customer',
|
||||
rows: [
|
||||
{ label: 'Client ID', value: String(c.clientId ?? '—') },
|
||||
{ label: 'Name', value: c.name ?? '—' },
|
||||
{ label: 'Client type', value: c.clientType ?? '—' },
|
||||
{ label: "Father's name", value: c.fatherName ?? '—' },
|
||||
{ label: 'Gender', value: c.gender ?? '—' },
|
||||
{
|
||||
label: 'Date of birth',
|
||||
value: Array.isArray(c.dateOfBirth) ? c.dateOfBirth.slice(0, 3).reverse().join('-') : (c.dateOfBirth ?? '—')
|
||||
},
|
||||
{ label: 'Mobile', value: c.mobile ?? '—' },
|
||||
{ label: 'Occupation', value: c.occupation ?? '—' },
|
||||
{ label: 'PAN', value: c.pan ?? '—' },
|
||||
{ label: 'Aadhaar', value: c.aadhaar ?? '—' },
|
||||
{ label: 'KYC status', value: c.kycStatus ?? '—' },
|
||||
{ label: 'Risk', value: c.risk ?? '—' },
|
||||
{
|
||||
label: 'Address',
|
||||
value: a
|
||||
? [
|
||||
...a.lines,
|
||||
a.city,
|
||||
a.district,
|
||||
a.state,
|
||||
a.pincode
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: '—'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
/** An entity (company, firm, trust) has related persons -- its authorised signatories -- shown on their own step; a person has none. */
|
||||
losIsNonIndividual = false;
|
||||
losRelatedPersons: {
|
||||
clientId: number | null;
|
||||
title: string;
|
||||
name: string;
|
||||
pincode: string;
|
||||
birthDate: any;
|
||||
address: string;
|
||||
loading: boolean;
|
||||
}[] = [];
|
||||
|
||||
//** Defining PlaceHolders for the search bar */
|
||||
placeHolderLabel = '';
|
||||
@@ -231,22 +135,7 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
||||
this.createLoansAccountDetailsForm();
|
||||
}
|
||||
|
||||
/** The category and type of the application's product, named for the read-only fields. */
|
||||
get losCategoryLabel(): string {
|
||||
return PRODUCT_TYPE_GROUPS.find((g) => g.id === this.categoryFilter)?.label ?? '';
|
||||
}
|
||||
|
||||
get losTypeLabel(): string {
|
||||
return (PRODUCT_SUB_TYPES[this.categoryFilter] ?? []).find((s) => s.id === this.typeFilter)?.label ?? '';
|
||||
}
|
||||
|
||||
/** True when the screen was opened from a Loan Origination application. */
|
||||
get losMode(): boolean {
|
||||
return !!this.losApplicationNo;
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.losApplicationNo = this.route.snapshot.queryParamMap.get('los');
|
||||
this.placeHolderLabel = this.translateService.instant('labels.text.Search');
|
||||
this.noEntriesFoundLabel = this.translateService.instant('labels.text.No data found');
|
||||
this.maxDate = this.settingsService.maxFutureDate;
|
||||
@@ -275,279 +164,6 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
this.productData.next(this.productList.slice());
|
||||
this.loadActiveProducts();
|
||||
if (this.losMode) {
|
||||
this.loadLosApplication();
|
||||
} else {
|
||||
this.loadLosCandidates();
|
||||
this.loansAccountDetailsForm
|
||||
.get('productId')
|
||||
?.valueChanges.pipe(takeUntil(this._onDestroy))
|
||||
.subscribe(() => this.refreshLosChoices());
|
||||
}
|
||||
}
|
||||
|
||||
private sameName(a: string | undefined, b: string | undefined): boolean {
|
||||
const clean = (v: string | undefined) => (v ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
return !!clean(a) && clean(a) === clean(b);
|
||||
}
|
||||
|
||||
/** Reads this customer's applications that are ready for an account and keeps those with no account yet. */
|
||||
private loadLosCandidates(): void {
|
||||
const clientId = Number(this.loansAccountTemplate?.clientId);
|
||||
if (!clientId) {
|
||||
return;
|
||||
}
|
||||
forkJoin({
|
||||
apps: this.originationService.list().pipe(catchError(() => of([] as LoanApplication[]))),
|
||||
catalog: this.catalog.activeProducts().pipe(catchError(() => of([])))
|
||||
}).subscribe(({ apps }) => {
|
||||
const ready = apps.filter(
|
||||
(a) => a.clientId === clientId && a.overallStatus === 'IN_PROGRESS' && a.currentStage === 'CUSTOMER_SIGNING'
|
||||
);
|
||||
if (!ready.length) {
|
||||
return;
|
||||
}
|
||||
// an application that already has its account cannot be linked a second time
|
||||
forkJoin(ready.map((a) => this.originationService.loanForApplication(a.applicationNo))).subscribe((loans) => {
|
||||
this.losCandidates = ready
|
||||
.filter((_a, i) => !loans[i])
|
||||
.map((app) => {
|
||||
const product = this.allProducts.find((p: any) => this.sameName(p.name, app.productName));
|
||||
const ext = product ? this.catalog.classification(product.id) : {};
|
||||
return {
|
||||
app,
|
||||
productId: product ? Number(product.id) : 0,
|
||||
group: ext['productTypeGroup'] ?? '',
|
||||
type: ext['productTypeSubGroup'] ?? '',
|
||||
label:
|
||||
app.applicationNo +
|
||||
' · ' +
|
||||
(app.productName ?? '') +
|
||||
' · ₹' +
|
||||
Number(app.requestedAmount).toLocaleString('en-IN') +
|
||||
' · ' +
|
||||
app.tenureMonths +
|
||||
' months'
|
||||
};
|
||||
})
|
||||
.filter((c) => c.productId > 0);
|
||||
this.refreshLosChoices();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The applications that fit what is chosen above: category, product type and product. Nothing chosen shows all of them. */
|
||||
refreshLosChoices(): void {
|
||||
const productId = Number(this.loansAccountDetailsForm.get('productId')?.value) || 0;
|
||||
this.losChoices = this.losCandidates.filter(
|
||||
(c) =>
|
||||
(!this.categoryFilter || c.group === this.categoryFilter) &&
|
||||
(!this.typeFilter || c.type === this.typeFilter) &&
|
||||
(!productId || c.productId === productId)
|
||||
);
|
||||
}
|
||||
|
||||
/** The product picked is an Advances product (by its classification): the Limit & Security and SMS Alerts steps apply to it. */
|
||||
get advancesSelected(): boolean {
|
||||
const id = this.loansAccountDetailsForm?.get('productId')?.value;
|
||||
return !!id && this.catalog.classification(id)['productTypeGroup'] === 'advances';
|
||||
}
|
||||
|
||||
get hasLosCandidates(): boolean {
|
||||
return this.losCandidates.length > 0;
|
||||
}
|
||||
|
||||
/** Opens the screen for the chosen application: the same screen as when it is opened from Loan Origination. */
|
||||
useLosApplication(applicationNo: string): void {
|
||||
const clientId = this.loansAccountTemplate?.clientId;
|
||||
if (!applicationNo || !clientId) {
|
||||
return;
|
||||
}
|
||||
window.location.hash = '#/clients/' + clientId + '/loans-accounts/create?los=' + encodeURIComponent(applicationNo);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
/** Reads the application and sets the category, type and product from it; the product is then fixed. */
|
||||
private loadLosApplication(): void {
|
||||
this.originationService.get(this.losApplicationNo as string).subscribe({
|
||||
next: (app) => {
|
||||
this.losApplication = app;
|
||||
// An application can only be linked to its own customer, and only once it is ready for an account.
|
||||
const clientId = Number(this.loansAccountTemplate?.clientId);
|
||||
if (clientId && app.clientId !== clientId) {
|
||||
this.losError =
|
||||
'Application ' + app.applicationNo + ' belongs to another customer and cannot be linked to this account.';
|
||||
this.losApplication = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.route.snapshot.queryParamMap.get('view') !== '1' &&
|
||||
(app.currentStage !== 'CUSTOMER_SIGNING' || app.overallStatus !== 'IN_PROGRESS')
|
||||
) {
|
||||
this.losError =
|
||||
'Application ' +
|
||||
app.applicationNo +
|
||||
' is not at Customer Signing, so an account cannot be opened for it yet.';
|
||||
this.losApplication = null;
|
||||
return;
|
||||
}
|
||||
const wanted = (app.productName ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
const product = this.allProducts.find(
|
||||
(p: any) => (p.name ?? '').trim().toLowerCase().replace(/\s+/g, ' ') === wanted
|
||||
);
|
||||
if (!product) {
|
||||
this.losError = 'The product "' + app.productName + '" was not found among the loan products.';
|
||||
} else {
|
||||
// The product catalog is read first, so its category and type are known when they are named.
|
||||
this.catalog.activeProducts().subscribe(() => {
|
||||
const ext = this.catalog.classification(product.id);
|
||||
this.categoryFilter = ext['productTypeGroup'] ?? '';
|
||||
this.typeFilter = ext['productTypeSubGroup'] ?? '';
|
||||
this.losProductName = product.name;
|
||||
const today = this.settingsService.businessDate;
|
||||
this.loansAccountDetailsForm.patchValue({
|
||||
productId: product.id,
|
||||
submittedOnDate: today,
|
||||
expectedDisbursementDate: today
|
||||
});
|
||||
});
|
||||
}
|
||||
this.loadLosCustomer(app);
|
||||
},
|
||||
error: () => {
|
||||
this.losError = 'Could not read the loan origination application ' + this.losApplicationNo + '.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity's authorised signatories are stored as two comma-separated columns, names and client ids in the same order. Each one
|
||||
* linked to a client is filled in from that client's own record (title, name, birth date, address, pincode).
|
||||
*/
|
||||
private loadLosRelatedPersons(entity: any): void {
|
||||
const split = (v: any): string[] =>
|
||||
String(v ?? '')
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
const names = split(entity?.clientNonPersonDetails?.authorizedSignatory);
|
||||
const ids = split(entity?.clientNonPersonDetails?.authorizedSignatoryClientIds);
|
||||
const count = Math.max(names.length, ids.length);
|
||||
this.losRelatedPersons = Array.from({ length: count }, (_u, i) => {
|
||||
const id = ids[i] && Number.isFinite(Number(ids[i])) ? Number(ids[i]) : null;
|
||||
return {
|
||||
clientId: id,
|
||||
title: '',
|
||||
name: names[i] ?? '',
|
||||
pincode: '',
|
||||
birthDate: null as any,
|
||||
address: '',
|
||||
loading: id !== null
|
||||
};
|
||||
});
|
||||
const quiet = catchError(() => of(null));
|
||||
this.losRelatedPersons
|
||||
.filter((r) => r.clientId !== null)
|
||||
.forEach((row) => {
|
||||
forkJoin({
|
||||
person: this.clientsService.getClientData(String(row.clientId), true).pipe(quiet),
|
||||
address: this.clientsService.getClientAddressData(String(row.clientId), true).pipe(quiet)
|
||||
}).subscribe(({ person, address }: any) => {
|
||||
const list: any[] = Array.isArray(address) ? address : [];
|
||||
const addr = list.find((a) => a.isActive) ?? list[0];
|
||||
row.title = person?.salutation?.name ?? '';
|
||||
row.name = person?.displayName ?? row.name;
|
||||
row.birthDate = person?.dateOfBirth ?? null;
|
||||
row.pincode = addr?.postalCode ?? '';
|
||||
row.address = addr
|
||||
? [
|
||||
addr.addressLine1,
|
||||
addr.addressLine2,
|
||||
addr.addressLine3,
|
||||
addr.city,
|
||||
addr.stateName
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: '';
|
||||
row.loading = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The customer's id, name, address, risk and the rest a loan needs, from the client record (nothing typed again). */
|
||||
private loadLosCustomer(app: LoanApplication): void {
|
||||
const clientId = String(app.clientId ?? this.loansAccountTemplate?.clientId ?? '');
|
||||
if (!clientId) {
|
||||
this.losCustomer = { missing: true };
|
||||
return;
|
||||
}
|
||||
const quiet = catchError(() => of(null));
|
||||
forkJoin({
|
||||
client: this.clientsService.getClientData(clientId, true).pipe(quiet),
|
||||
kyc: this.clientsService.getClientKyc(clientId, true).pipe(quiet),
|
||||
address: this.clientsService.getClientAddressData(clientId, true).pipe(quiet),
|
||||
family: this.clientsService.getClientFamilyMembers(clientId).pipe(quiet)
|
||||
}).subscribe(({ client, kyc, address, family }: any) => {
|
||||
const addresses: any[] = Array.isArray(address) ? address : [];
|
||||
const addr =
|
||||
addresses.find((a) => a.isActive && /permanent/i.test(a.addressType ?? '')) ??
|
||||
addresses.find((a) => a.isActive) ??
|
||||
addresses[0];
|
||||
const members: any[] = Array.isArray(family) ? family : [];
|
||||
const father = members.find((m) => /father/i.test(m.relationship ?? ''));
|
||||
const riskLabels: Record<number, string> = { 1: 'Low', 2: 'Medium', 3: 'High' };
|
||||
this.losIsNonIndividual = !!client?.legalForm && client.legalForm.id !== 1;
|
||||
if (this.losIsNonIndividual) {
|
||||
this.loadLosRelatedPersons(client);
|
||||
}
|
||||
this.losCustomer = {
|
||||
clientId: client?.id ?? clientId,
|
||||
accountNo: client?.accountNo,
|
||||
name: client?.displayName ?? app.applicantName,
|
||||
// Individual or Non-individual, from the client's legal form (an entity -- firm, company, trust -- is non-individual).
|
||||
clientType: client?.legalForm
|
||||
? /^legalFormType\.individual$/i.test(client.legalForm.code ?? '') || client.legalForm.id === 1
|
||||
? 'Individual'
|
||||
: 'Non-Individual'
|
||||
: null,
|
||||
fatherName: father
|
||||
? [
|
||||
father.firstName,
|
||||
father.middleName,
|
||||
father.lastName
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: null,
|
||||
gender: client?.gender?.name,
|
||||
dateOfBirth: client?.dateOfBirth,
|
||||
mobile: client?.mobileNo,
|
||||
occupation: client?.occupation?.name ?? client?.occupationCategory?.name,
|
||||
nationality: client?.nationality,
|
||||
branch: client?.officeName ?? app.branchName,
|
||||
minor: client?.isMinor === true,
|
||||
address: addr
|
||||
? {
|
||||
lines: [
|
||||
addr.addressLine1,
|
||||
addr.addressLine2,
|
||||
addr.addressLine3
|
||||
].filter(Boolean),
|
||||
city: addr.city,
|
||||
district: addr.countyDistrict,
|
||||
state: addr.stateName,
|
||||
pincode: addr.postalCode
|
||||
}
|
||||
: null,
|
||||
pan: kyc?.panNumber,
|
||||
panVerified: !!kyc?.panVerified,
|
||||
aadhaar: kyc?.aadhaarNumberMasked,
|
||||
aadhaarVerified: !!kyc?.aadhaarVerified,
|
||||
kycStatus: kyc?.kycStatusCode ? String(kyc.kycStatusCode).replace('clientKycStatus.', '') : null,
|
||||
risk: kyc ? (riskLabels[kyc.riskCategory] ?? null) : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -574,7 +190,6 @@ export class LoansAccountDetailsStepComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** Rebuilds the product list from the template: active only, then category and type, then the search text. */
|
||||
applyProductFilters(): void {
|
||||
this.refreshLosChoices();
|
||||
this.productList = this.allProducts.filter((product: any) => {
|
||||
if (this.activeProductIds && !this.activeProductIds.has(Number(product.id))) {
|
||||
return false;
|
||||
|
||||
-51
@@ -1,51 +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/.
|
||||
-->
|
||||
|
||||
<h4 class="mat-h4">Guarantor master</h4>
|
||||
<p class="lgs-note">
|
||||
Guarantors required by the product: <strong>{{ required }}</strong> · named at Loan Origination:
|
||||
<strong>{{ rows.length }}</strong>
|
||||
</p>
|
||||
|
||||
<table class="lgs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client ID</th>
|
||||
<th>Guarantor name</th>
|
||||
<th>Membership (share a/c)</th>
|
||||
<th>Member</th>
|
||||
<th>Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track row.clientId) {
|
||||
<tr>
|
||||
<td>{{ row.clientId }}</td>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.membership }}</td>
|
||||
<td>{{ row.member ? 'Yes' : 'No' }}</td>
|
||||
<td>{{ row.address }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5">No guarantor was named for this application.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
|
||||
<button mat-raised-button matStepperPrevious>
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
Previous
|
||||
</button>
|
||||
<button mat-raised-button matStepperNext>
|
||||
Next
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
-32
@@ -1,32 +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/.
|
||||
*/
|
||||
|
||||
.lgs-note {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.lgs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
-119
@@ -1,119 +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, Input, OnChanges, inject } from '@angular/core';
|
||||
import { MatStepperNext, MatStepperPrevious } from '@angular/material/stepper';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { LoanApplication } from 'app/loan-origination/loan-origination-data';
|
||||
import { LosPreviewSection } from '../los-preview-section';
|
||||
|
||||
interface GuarantorRow {
|
||||
clientId: number;
|
||||
name: string;
|
||||
membership: string;
|
||||
member: boolean;
|
||||
address: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarantors: the people named for this loan at Loan Origination (each an onboarded client), with their membership (share
|
||||
* account) and address from the client record. Read-only here.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-loans-account-los-guarantors-step',
|
||||
templateUrl: './loans-account-los-guarantors-step.component.html',
|
||||
styleUrls: ['./loans-account-los-guarantors-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatStepperPrevious,
|
||||
MatStepperNext,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class LoansAccountLosGuarantorsStepComponent implements OnChanges {
|
||||
private clientsService = inject(ClientsService);
|
||||
|
||||
@Input() losApplication: LoanApplication | null = null;
|
||||
|
||||
rows: GuarantorRow[] = [];
|
||||
private loadedFor: string | null = null;
|
||||
|
||||
get required(): number {
|
||||
return this.losApplication?.guarantorsRequiredCount ?? 0;
|
||||
}
|
||||
|
||||
/** Everything on this step, for the Preview. */
|
||||
previewSections(): LosPreviewSection[] {
|
||||
return [
|
||||
{
|
||||
title: 'Guarantors',
|
||||
rows: this.rows.map((r, i) => ({
|
||||
label: 'Guarantor ' + (i + 1),
|
||||
value:
|
||||
r.name +
|
||||
' (client ' +
|
||||
r.clientId +
|
||||
') · member ' +
|
||||
(r.member ? 'yes, ' + r.membership : 'no') +
|
||||
' · ' +
|
||||
r.address
|
||||
}))
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
const app = this.losApplication;
|
||||
if (!app || this.loadedFor === app.applicationNo) {
|
||||
return;
|
||||
}
|
||||
this.loadedFor = app.applicationNo;
|
||||
const guarantors = app.guarantors ?? [];
|
||||
this.rows = guarantors.map((g) => ({
|
||||
clientId: g.clientId,
|
||||
name: g.name,
|
||||
membership: g.shareAccountNos?.join(', ') || '—',
|
||||
member: g.hasShareAccount,
|
||||
address: '…'
|
||||
}));
|
||||
if (!guarantors.length) {
|
||||
return;
|
||||
}
|
||||
forkJoin(
|
||||
guarantors.map((g) =>
|
||||
this.clientsService.getClientAddressData(String(g.clientId), true).pipe(catchError(() => of([])))
|
||||
)
|
||||
).subscribe((all: any[]) => {
|
||||
this.rows = this.rows.map((row, i) => {
|
||||
const list: any[] = Array.isArray(all[i]) ? all[i] : [];
|
||||
const a =
|
||||
list.find((x) => x.isActive && /permanent/i.test(x.addressType ?? '')) ??
|
||||
list.find((x) => x.isActive) ??
|
||||
list[0];
|
||||
const text = a
|
||||
? [
|
||||
a.addressLine1,
|
||||
a.addressLine2,
|
||||
a.addressLine3,
|
||||
a.townVillage,
|
||||
a.city,
|
||||
a.countyDistrict,
|
||||
a.postalCode
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: '';
|
||||
return { ...row, address: text || '—' };
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
-171
@@ -1,171 +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/.
|
||||
-->
|
||||
|
||||
<form [formGroup]="form">
|
||||
<div class="lls-grid">
|
||||
<div>
|
||||
<span class="lls-label">Nature of limit</span><span>{{ natureOfLimit }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="lls-label">Security type</span><span>{{ securityType }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="lls-label">Secured / Unsecured</span><span>{{ secured ? 'Secured' : 'Unsecured' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="lls-label">Priority sector</span><span>{{ prioritySector ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="lls-label">Sanction date</span><span>{{ sanctionDate }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="lls-label">Sanction no.</span><span>{{ sanctionNo }}</span>
|
||||
</div>
|
||||
@if (!manual) {
|
||||
<div>
|
||||
<span class="lls-label">Sanction limit</span
|
||||
><span>₹{{ losApplication?.requestedAmount | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="lls-label">Period</span><span>{{ losApplication?.tenureMonths }} months</span>
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<span class="lls-label">Margin %</span><span>{{ productExt['margin'] ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (manual) {
|
||||
<p class="lls-note">
|
||||
This account is being opened without a Loan Origination application, so the sanction is entered here. To take it,
|
||||
and the security, from an application, pick one on the Details step.
|
||||
</p>
|
||||
<div class="layout-row-wrap layout-lt-md-column gap-2percent lls-inputs">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Sanction no.</mat-label>
|
||||
<input matInput formControlName="sanctionNoInput" maxlength="40" required />
|
||||
<mat-error>Sanction no. is <strong>required</strong></mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Sanction date</mat-label>
|
||||
<input matInput type="date" formControlName="sanctionDateInput" required />
|
||||
<mat-error>Sanction date is <strong>required</strong></mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="layout-row-wrap layout-lt-md-column gap-2percent lls-inputs">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Purpose of the loan</mat-label>
|
||||
<input matInput formControlName="purpose" maxlength="200" required />
|
||||
<mat-error>Purpose is <strong>required</strong></mat-error>
|
||||
</mat-form-field>
|
||||
@if (prioritySector) {
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Priority sector category</mat-label>
|
||||
<mat-select formControlName="prioritySectorCategory" required>
|
||||
@for (category of prioritySectorCategories; track category) {
|
||||
<mat-option [value]="category">{{ category }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error>Priority sector category is <strong>required</strong></mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (showRenewal) {
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Document renewal date</mat-label>
|
||||
<input matInput type="date" formControlName="documentRenewalDate" />
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h4 class="mat-h4">Product terms</h4>
|
||||
<div class="lls-grid lls-grid--plain">
|
||||
@for (row of productRows; track row.label) {
|
||||
<div>
|
||||
<span class="lls-label">{{ row.label }}</span
|
||||
><span>{{ row.value }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h4 class="mat-h4">SMA</h4>
|
||||
<div class="lls-grid lls-grid--plain">
|
||||
@for (row of smaRows; track row.label) {
|
||||
<div>
|
||||
<span class="lls-label">{{ row.label }}</span
|
||||
><span>{{ row.value }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h4 class="mat-h4">NPA</h4>
|
||||
<div class="lls-grid lls-grid--plain">
|
||||
@for (row of npaRows; track row.label) {
|
||||
<div>
|
||||
<span class="lls-label">{{ row.label }}</span
|
||||
><span>{{ row.value }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!manual) {
|
||||
<h4 class="mat-h4">As sanctioned</h4>
|
||||
<div class="lls-grid lls-grid--plain">
|
||||
@for (row of sanctionRows; track row.label) {
|
||||
<div>
|
||||
<span class="lls-label">{{ row.label }}</span
|
||||
><span>{{ row.value }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h4 class="mat-h4">Security details</h4>
|
||||
<table class="lls-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Type</th>
|
||||
<th>Owner</th>
|
||||
<th>Value</th>
|
||||
<th>Rate</th>
|
||||
<th>Maturity</th>
|
||||
<th>Lien for this loan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of security; track row.reference) {
|
||||
<tr>
|
||||
<td>{{ row.reference }}</td>
|
||||
<td>{{ row.kind }}</td>
|
||||
<td>{{ row.owner }}</td>
|
||||
<td>₹{{ row.value | number: '1.2-2' }}</td>
|
||||
<td>{{ row.rate }}</td>
|
||||
<td>{{ row.maturity }}</td>
|
||||
<td>₹{{ row.lien | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="7">No security is recorded for this application.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
|
||||
<button mat-raised-button matStepperPrevious>
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
Previous
|
||||
</button>
|
||||
<button mat-raised-button matStepperNext [disabled]="form.invalid">
|
||||
Next
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
-64
@@ -1,64 +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/.
|
||||
*/
|
||||
|
||||
.lls-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 12px 16px;
|
||||
border: 1px solid #c9d7f2;
|
||||
border-radius: 8px;
|
||||
background: #f3f7ff;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.lls-grid--plain {
|
||||
background: #fff;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.lls-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #5b6b8c;
|
||||
}
|
||||
|
||||
.lls-inputs {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.lls-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.lls-note {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 10px;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
}
|
||||
-493
@@ -1,493 +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, Input, OnChanges, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
|
||||
import { MatStepperNext, MatStepperPrevious } from '@angular/material/stepper';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import {
|
||||
LoanApplication,
|
||||
PRIORITY_SECTOR_CATEGORIES,
|
||||
depositKindCode,
|
||||
sanctioningAuthority
|
||||
} from 'app/loan-origination/loan-origination-data';
|
||||
import { LoanOriginationService } from 'app/loan-origination/loan-origination.service';
|
||||
import { LosPreviewSection } from '../los-preview-section';
|
||||
|
||||
/** One pledged deposit or policy, as listed under the limit. */
|
||||
interface SecurityRow {
|
||||
reference: string;
|
||||
kind: string;
|
||||
owner: string;
|
||||
value: number;
|
||||
rate: string;
|
||||
maturity: string;
|
||||
lien: number;
|
||||
}
|
||||
|
||||
interface InfoRow {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit & Security: what an Advances account is for, taken from the Loan Origination application and the product, with the
|
||||
* security (the pledged FD / RD / DDS or LIC policy) listed below. The officer only gives the purpose and the date the
|
||||
* documents are due for renewal.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-loans-account-los-limit-step',
|
||||
templateUrl: './loans-account-los-limit-step.component.html',
|
||||
styleUrls: ['./loans-account-los-limit-step.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatStepperPrevious,
|
||||
MatStepperNext,
|
||||
FaIconComponent
|
||||
]
|
||||
})
|
||||
export class LoansAccountLosLimitStepComponent implements OnChanges {
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private http = inject(HttpClient);
|
||||
private originationService = inject(LoanOriginationService);
|
||||
|
||||
@Input() losApplication: LoanApplication | null = null;
|
||||
@Input() productId: any = null;
|
||||
|
||||
readonly form: UntypedFormGroup = this.formBuilder.group({
|
||||
purpose: [
|
||||
'',
|
||||
Validators.required
|
||||
],
|
||||
documentRenewalDate: [''],
|
||||
prioritySectorCategory: [''],
|
||||
// Asked only when the account is opened without a Loan Origination application (nothing to take them from).
|
||||
sanctionNoInput: [''],
|
||||
sanctionDateInput: ['']
|
||||
});
|
||||
|
||||
/** RBI's priority sector lending categories. */
|
||||
/** From the system Code "PrioritySectorCategory"; the built-in list shows until it is read. */
|
||||
prioritySectorCategories: string[] = PRIORITY_SECTOR_CATEGORIES;
|
||||
|
||||
productExt: Record<string, any> = {};
|
||||
productName = '';
|
||||
security: SecurityRow[] = [];
|
||||
private loadedFor: string | null = null;
|
||||
|
||||
/** True when the account is opened without a Loan Origination application: the sanction is typed in, there is no security list. */
|
||||
get manual(): boolean {
|
||||
return !this.losApplication;
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
const app = this.losApplication;
|
||||
if (!this.productId) {
|
||||
return;
|
||||
}
|
||||
// the sanction number and date are needed only when there is no application to take them from
|
||||
for (const name of [
|
||||
'sanctionNoInput',
|
||||
'sanctionDateInput'
|
||||
]) {
|
||||
const control = this.form.get(name)!;
|
||||
control.setValidators(app ? null : Validators.required);
|
||||
control.updateValueAndValidity({ emitEvent: false });
|
||||
}
|
||||
const key = (app?.applicationNo ?? 'manual') + '|' + this.productId;
|
||||
if (this.loadedFor === key) {
|
||||
return;
|
||||
}
|
||||
this.loadedFor = key;
|
||||
this.originationService.prioritySectorCategories().subscribe((names) => (this.prioritySectorCategories = names));
|
||||
this.http.get<any>('/loanproducts/' + this.productId).subscribe((product) => {
|
||||
this.productName = product?.name ?? '';
|
||||
this.productExt = product?.advanceExtensions ?? {};
|
||||
const category = this.form.get('prioritySectorCategory')!;
|
||||
category.setValidators(this.prioritySector ? Validators.required : null);
|
||||
// the category was set at Sanction in Loan Origination: it is shown here, not chosen again
|
||||
if (this.prioritySector && app?.prioritySectorCategory) {
|
||||
category.setValue(app.prioritySectorCategory);
|
||||
category.disable();
|
||||
} else {
|
||||
category.enable();
|
||||
}
|
||||
category.updateValueAndValidity();
|
||||
// the purpose was given in Loan Origination after KYC: shown here, not asked again
|
||||
const purpose = this.form.get('purpose')!;
|
||||
if (app?.loanPurpose?.trim()) {
|
||||
purpose.setValue(app.loanPurpose.trim());
|
||||
purpose.disable();
|
||||
} else {
|
||||
purpose.enable();
|
||||
}
|
||||
this.setRenewalDefault();
|
||||
});
|
||||
if (app) {
|
||||
this.loadSecurity(app);
|
||||
} else {
|
||||
this.security = [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Priority sector: the officer's decision at Sanction in Loan Origination, else what the product says. */
|
||||
get prioritySector(): boolean {
|
||||
const decided = this.losApplication?.pslClassified;
|
||||
if (decided) {
|
||||
return decided === 'yes';
|
||||
}
|
||||
return String(this.productExt['prioritySector'] ?? '').toLowerCase() === 'yes';
|
||||
}
|
||||
|
||||
/** Weaker section, as decided in Loan Origination. */
|
||||
get weakerSection(): boolean {
|
||||
return this.losApplication?.weakerSection === 'yes';
|
||||
}
|
||||
|
||||
/** The purpose the officer typed. */
|
||||
get purposeText(): string {
|
||||
return String(this.form.get('purpose')!.value ?? '').trim();
|
||||
}
|
||||
|
||||
/** A loan on a deposit (FD / RD / DDS / LIC) runs for a fixed tenure; its documents are not renewed. */
|
||||
get showRenewal(): boolean {
|
||||
return this.losApplication ? !this.losApplication.depositSelection : !!this.productExt['renewalAllowed'];
|
||||
}
|
||||
|
||||
/** The nature of the limit: the loan product (Loan against FD, LIC Policy, Cash Credit ...). */
|
||||
get natureOfLimit(): string {
|
||||
return this.losApplication?.productName ?? (this.productName || '—');
|
||||
}
|
||||
|
||||
get securityType(): string {
|
||||
return this.productExt['securityType'] ?? '—';
|
||||
}
|
||||
|
||||
/** Secured when the product is backed by a security; a product whose security type is none is unsecured. */
|
||||
get secured(): boolean {
|
||||
const type = String(this.productExt['securityType'] ?? '').trim();
|
||||
return !!type && !/^none/i.test(type);
|
||||
}
|
||||
|
||||
/** The date the limit was sanctioned: the day the sanctioning authority approved it. */
|
||||
get sanctionDate(): string {
|
||||
const app = this.losApplication;
|
||||
if (!app) {
|
||||
// typed in as yyyy-MM-dd, shown as dd-MM-yyyy
|
||||
const typed = String(this.form.get('sanctionDateInput')!.value ?? '');
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typed);
|
||||
return m ? m[3] + '-' + m[2] + '-' + m[1] : '—';
|
||||
}
|
||||
return app?.creditCommitteeApproval?.date && app.creditCommitteeApproval.date !== '—'
|
||||
? app.creditCommitteeApproval.date
|
||||
: '—';
|
||||
}
|
||||
|
||||
/** The sanction number: the committee's resolution number, else the application number. */
|
||||
get sanctionNo(): string {
|
||||
const app = this.losApplication;
|
||||
if (!app) {
|
||||
return String(this.form.get('sanctionNoInput')!.value ?? '').trim() || '—';
|
||||
}
|
||||
return app?.committeeResolutionNo || app?.applicationNo || '—';
|
||||
}
|
||||
|
||||
/** What the product was created with (Product Creation) that bears on this limit. */
|
||||
get productRows(): InfoRow[] {
|
||||
const e = this.productExt;
|
||||
const yesNo = (v: any) => (v === true || v === 'true' ? 'Yes' : 'No');
|
||||
const basis: Record<string, string> = {
|
||||
balance_accrued: 'Deposit balance + accrued interest',
|
||||
balance: 'Deposit balance only',
|
||||
surrender: 'Surrender value',
|
||||
gold_value: 'Gold value',
|
||||
commodity_value: 'Commodity value'
|
||||
};
|
||||
const rows: InfoRow[] = [
|
||||
{ label: 'Facility', value: e['facilityType'] ?? '—' },
|
||||
{ label: 'Maximum LTV', value: e['ltv'] !== undefined ? e['ltv'] + '%' : '—' },
|
||||
{ label: 'Eligible amount basis', value: basis[e['eligibleAmountBasis']] ?? e['eligibleAmountBasis'] ?? '—' },
|
||||
{
|
||||
label: 'Lien marked',
|
||||
value: (e['lienMarkingStage'] ?? '—') === 'sanction' ? 'At sanction' : 'At disbursement'
|
||||
},
|
||||
{
|
||||
label: 'Renewal',
|
||||
value: e['renewalAllowed']
|
||||
? 'Allowed, every ' +
|
||||
(e['renewalPeriodMonths'] ?? '—') +
|
||||
' months (grace ' +
|
||||
(e['renewalGraceDays'] ?? 0) +
|
||||
' days)'
|
||||
: 'Not allowed'
|
||||
},
|
||||
{ label: 'Pre-closure', value: e['preclosureAllowed'] ?? '—' },
|
||||
{ label: 'Lock-in', value: (e['lockInMonths'] ?? 0) + ' months' },
|
||||
{ label: 'Tenure within security maturity', value: yesNo(e['lienTenureNotExceedMaturity']) }
|
||||
];
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** When the account is watched as SMA (special mention), from the product. */
|
||||
get smaRows(): InfoRow[] {
|
||||
const sma = Number(this.productExt['smaStartAfterMonths'] ?? 0);
|
||||
return [
|
||||
{
|
||||
label: 'SMA counting starts',
|
||||
value: sma > 0 ? sma + ' month' + (sma > 1 ? 's' : '') + ' after disbursement' : 'From disbursement'
|
||||
},
|
||||
{ label: 'SMA-0', value: 'Overdue 1 to 30 days' },
|
||||
{ label: 'SMA-1', value: 'Overdue 31 to 60 days' },
|
||||
{ label: 'SMA-2', value: 'Overdue 61 to 90 days' }
|
||||
];
|
||||
}
|
||||
|
||||
/** When the account turns NPA, in words, from the product's NPA settings. */
|
||||
get npaRows(): InfoRow[] {
|
||||
const e = this.productExt;
|
||||
const outOfOrder: Record<string, string> = {
|
||||
LIMIT_EXCEEDED: 'the outstanding stays above the limit / drawing power continuously',
|
||||
NO_CREDITS_90_DAYS: 'there are no credits for 90 days, or credits are less than the interest debited',
|
||||
DP_NOT_RENEWED: 'the drawing power is not reassessed or renewed within 180 days'
|
||||
};
|
||||
const trigger: Record<string, string> = {
|
||||
maturity: 'Dues stay overdue for 90 days (after maturity, or after an instalment falls due)',
|
||||
out_of_order: 'The account is "out of order": ' + (outOfOrder[e['npaOutOfOrderBasis']] ?? '—'),
|
||||
crop_season: 'Dues stay overdue for the crop season(s) (KCC)'
|
||||
};
|
||||
const interest: Record<string, string> = {
|
||||
undebited: 'Moved to the Undebited Interest GL',
|
||||
suspense: 'Moved to the Interest Suspense GL'
|
||||
};
|
||||
const rows: InfoRow[] = [
|
||||
{ label: 'NPA when', value: trigger[e['npaTrigger']] ?? e['npaTrigger'] ?? '—' },
|
||||
{ label: 'Interest after NPA', value: interest[e['interestAfterNpa']] ?? e['interestAfterNpa'] ?? '—' }
|
||||
];
|
||||
// A stock statement gives the drawing power on stock or receivables; a deposit loan has none.
|
||||
if (e['stockStatementValidityDays'] && !this.losApplication?.depositSelection) {
|
||||
rows.push({ label: 'Stock statement valid for', value: e['stockStatementValidityDays'] + ' days' });
|
||||
}
|
||||
rows.push({
|
||||
label: 'Upgrade from NPA',
|
||||
value: e['npaUpgradeOnlyAllArrears'] ? 'Only when all arrears are cleared' : 'As per bank policy'
|
||||
});
|
||||
if (e['npaKeepStandardIfDepositCovers']) {
|
||||
rows.push({ label: 'Deposit-covered loan', value: 'Stays Standard while the deposit covers the dues' });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** What was decided on the application at Loan Origination. */
|
||||
get sanctionRows(): InfoRow[] {
|
||||
const app = this.losApplication;
|
||||
if (!app) {
|
||||
return [];
|
||||
}
|
||||
const approver = (field: string, label: string): InfoRow => {
|
||||
const ap = (app as any)[field];
|
||||
return {
|
||||
label,
|
||||
value: ap?.approverName && ap.approverName !== '—' ? ap.approverName + (ap.date ? ' · ' + ap.date : '') : '—'
|
||||
};
|
||||
};
|
||||
const sel = app.depositSelection;
|
||||
const share = sel?.securityValue
|
||||
? ((app.requestedAmount / sel.securityValue) * 100).toFixed(1) + '% of the security'
|
||||
: '—';
|
||||
// A loan against a deposit has the one Sanction step, no committee, risk or legal approval.
|
||||
const deposit = !!sel;
|
||||
return [
|
||||
{ label: 'Application no.', value: app.applicationNo },
|
||||
{ label: 'Sanctioning authority', value: sanctioningAuthority(app.requestedAmount) },
|
||||
{
|
||||
label: deposit ? 'Sanction no.' : 'Resolution',
|
||||
value: app.committeeResolutionNo ? app.committeeResolutionNo + ' dated ' + app.committeeResolutionDate : '—'
|
||||
},
|
||||
...(deposit ? [approver('creditCommitteeApproval', 'Sanctioned by')] : [
|
||||
approver('creditCommitteeApproval', 'Loan Committee'),
|
||||
approver('riskApproval', 'Risk approval'),
|
||||
approver('legalApproval', 'Legal approval')
|
||||
]),
|
||||
approver('postCheckApproval', 'Post-disbursement check'),
|
||||
{ label: 'Loan against the security', value: share },
|
||||
{ label: 'Eligible amount', value: sel ? '₹' + Number(sel.eligibleAmount).toLocaleString('en-IN') : '—' },
|
||||
{
|
||||
label: 'Lien certificate',
|
||||
value: app.lien?.certificateNo
|
||||
? app.lien.certificateNo + (app.lien.markedOn ? ' · ' + app.lien.markedOn : '')
|
||||
: 'Issued at disbursement'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/** A date written dd-MM-yyyy, as a Date (null when it is not a date). */
|
||||
private parse(text: string | undefined): Date | null {
|
||||
const m = /^(\d{1,2})-(\d{1,2})-(\d{4})$/.exec((text ?? '').trim());
|
||||
return m ? new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1])) : null;
|
||||
}
|
||||
|
||||
private iso(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||
}
|
||||
|
||||
/** The documents come up for renewal a renewal period (the product's, usually a year) after the sanction. */
|
||||
private setRenewalDefault(): void {
|
||||
if (!this.showRenewal || this.form.get('documentRenewalDate')!.value) {
|
||||
return;
|
||||
}
|
||||
const sanctioned = this.parse(this.sanctionDate);
|
||||
const months = Number(this.productExt['renewalPeriodMonths'] ?? 12) || 12;
|
||||
if (sanctioned) {
|
||||
sanctioned.setMonth(sanctioned.getMonth() + months);
|
||||
this.form.patchValue({ documentRenewalDate: this.iso(sanctioned) });
|
||||
}
|
||||
}
|
||||
|
||||
/** The deposits or the policy this limit is secured on, with what each is worth now. */
|
||||
private loadSecurity(app: LoanApplication): void {
|
||||
const sel = app.depositSelection;
|
||||
if (!sel) {
|
||||
return;
|
||||
}
|
||||
const lienShare = (value: number, total: number) =>
|
||||
total > 0 ? Math.round((value / total) * app.requestedAmount * 100) / 100 : app.requestedAmount;
|
||||
if (sel.policy) {
|
||||
this.security = [
|
||||
{
|
||||
reference: sel.policy.policyNo,
|
||||
kind: 'LIC policy (' + (sel.policy.insurer || 'LIC') + ')',
|
||||
owner: sel.policy.policyHolder,
|
||||
value: sel.policy.surrenderValue,
|
||||
rate: '—',
|
||||
maturity: sel.policy.maturityDate,
|
||||
lien: app.requestedAmount
|
||||
}
|
||||
];
|
||||
return;
|
||||
}
|
||||
const ids = sel.accountIds ?? [];
|
||||
if (depositKindCode(app.productCode) === 'DS') {
|
||||
this.originationService.listDdsAccounts(app.clientId as number).subscribe((parties) => {
|
||||
const picked = parties.filter((p: any) => ids.includes(p.id));
|
||||
const total = picked.reduce((s: number, p: any) => s + Number(p.currentBalance ?? 0), 0);
|
||||
this.security = picked.map((p: any) => ({
|
||||
reference: p.accountNo,
|
||||
kind: 'DDS',
|
||||
owner: app.applicantName,
|
||||
value: Number(p.currentBalance ?? 0),
|
||||
rate: p.interestRate + '%',
|
||||
maturity: String(p.maturityDate ?? '—'),
|
||||
lien: lienShare(Number(p.currentBalance ?? 0), total)
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
const kind = depositKindCode(app.productCode) === 'RD' ? 'RD' : 'FD';
|
||||
forkJoin(
|
||||
ids.map((id: number) => this.originationService.getDepositAccount(kind, id).pipe(catchError(() => of(null))))
|
||||
).subscribe((accounts: any[]) => {
|
||||
const list = accounts.filter((a) => !!a);
|
||||
const total = list.reduce((s, a) => s + Number(a.summary?.accountBalance ?? 0), 0);
|
||||
this.security = list.map((a) => ({
|
||||
reference: a.accountNo,
|
||||
kind: kind === 'RD' ? 'Recurring deposit' : 'Fixed deposit',
|
||||
owner: app.applicantName,
|
||||
value: Number(a.summary?.accountBalance ?? 0),
|
||||
rate: (a.nominalAnnualInterestRate ?? '—') + '%',
|
||||
maturity: Array.isArray(a.maturityDate)
|
||||
? a.maturityDate.slice(0, 3).reverse().join('-')
|
||||
: String(a.maturityDate ?? '—'),
|
||||
lien: lienShare(Number(a.summary?.accountBalance ?? 0), total)
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
/** Everything on this step, for the Preview. */
|
||||
previewSections(): LosPreviewSection[] {
|
||||
const f = this.form.getRawValue();
|
||||
const limit: InfoRow[] = [
|
||||
{ label: 'Nature of limit', value: this.natureOfLimit },
|
||||
{ label: 'Security type', value: this.securityType },
|
||||
{ label: 'Secured / Unsecured', value: this.secured ? 'Secured' : 'Unsecured' },
|
||||
{ label: 'Priority sector', value: this.prioritySector ? 'Yes' : 'No' },
|
||||
{ label: 'Sanction date', value: this.sanctionDate },
|
||||
{ label: 'Sanction no.', value: this.sanctionNo },
|
||||
{ label: 'Purpose of the loan', value: f.purpose || '—' }
|
||||
];
|
||||
if (this.prioritySector) {
|
||||
limit.push({ label: 'Priority sector category', value: f.prioritySectorCategory || '—' });
|
||||
}
|
||||
if (this.losApplication?.pslOverrideReason) {
|
||||
limit.push({ label: 'Priority sector — differs from product', value: this.losApplication.pslOverrideReason });
|
||||
}
|
||||
if (this.losApplication?.weakerSection) {
|
||||
limit.push({
|
||||
label: 'Weaker section',
|
||||
value: this.weakerSection ? 'Yes — ' + (this.losApplication.weakerSectionType || '—') : 'No'
|
||||
});
|
||||
}
|
||||
if (this.showRenewal) {
|
||||
limit.push({ label: 'Document renewal date', value: f.documentRenewalDate || '—' });
|
||||
}
|
||||
const security = this.security.map((s) => ({
|
||||
label: s.reference + ' (' + s.kind + ')',
|
||||
value:
|
||||
'Owner ' +
|
||||
s.owner +
|
||||
' · value ₹' +
|
||||
s.value.toLocaleString('en-IN') +
|
||||
' · maturity ' +
|
||||
s.maturity +
|
||||
' · lien ₹' +
|
||||
s.lien.toLocaleString('en-IN')
|
||||
}));
|
||||
return [
|
||||
{ title: 'Limit', rows: limit },
|
||||
...(this.manual
|
||||
? []
|
||||
: [{ title: 'Security', rows: security.length ? security : [{ label: 'Security', value: '—' }] }]),
|
||||
{ title: 'Product terms', rows: this.productRows },
|
||||
{ title: 'SMA', rows: this.smaRows },
|
||||
{ title: 'NPA', rows: this.npaRows },
|
||||
...(this.manual ? [] : [{ title: 'As sanctioned', rows: this.sanctionRows }])
|
||||
];
|
||||
}
|
||||
|
||||
/** What is saved with the loan account (the loan_account_services datatable); dates as the server's date format. */
|
||||
datatableValues(format: (isoDate: string) => string): Record<string, any> {
|
||||
const sanctioned = this.parse(this.sanctionDate);
|
||||
const data: Record<string, any> = {
|
||||
sanction_no: this.sanctionNo === '—' ? undefined : this.sanctionNo,
|
||||
secured: this.secured,
|
||||
security_type: this.productExt['securityType'] || undefined,
|
||||
loan_purpose_text: this.form.get('purpose')!.value?.trim() || undefined,
|
||||
priority_sector: this.prioritySector,
|
||||
priority_sector_category: (this.prioritySector && this.form.get('prioritySectorCategory')!.value) || undefined
|
||||
};
|
||||
if (this.losApplication?.weakerSection) {
|
||||
data['weaker_section'] = this.weakerSection;
|
||||
data['weaker_section_type'] = (this.weakerSection && this.losApplication.weakerSectionType) || undefined;
|
||||
}
|
||||
if (this.losApplication?.pslOverrideReason) {
|
||||
data['psl_override_reason'] = this.losApplication.pslOverrideReason;
|
||||
}
|
||||
if (sanctioned) {
|
||||
data['sanction_date'] = format(this.iso(sanctioned));
|
||||
}
|
||||
const renewal = this.showRenewal ? this.form.get('documentRenewalDate')!.value : '';
|
||||
if (renewal) {
|
||||
data['document_renewal_date'] = format(renewal);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
-80
@@ -1,80 +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/.
|
||||
-->
|
||||
|
||||
<form [formGroup]="form">
|
||||
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>SMS active</mat-label>
|
||||
<mat-select formControlName="smsActive">
|
||||
<mat-option value="yes">Yes</mat-option>
|
||||
<mat-option value="no">No</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (active) {
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>SMS charge</mat-label>
|
||||
<mat-select formControlName="smsCharge">
|
||||
<mat-option value="yes">Yes</mat-option>
|
||||
<mat-option value="no">No</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (active) {
|
||||
<h4 class="mat-h4">Free alerts</h4>
|
||||
<div class="sms-checks">
|
||||
<mat-checkbox formControlName="disbursementAlert">Account opened / limit sanctioned</mat-checkbox>
|
||||
<mat-checkbox formControlName="dueAlert">Interest due reminder</mat-checkbox>
|
||||
<mat-checkbox formControlName="overdueAlert">Overdue / limit exceeded notice</mat-checkbox>
|
||||
<mat-checkbox formControlName="renewAlert">Limit expiry / renewal reminder</mat-checkbox>
|
||||
<mat-checkbox formControlName="closureAlert">Account closed</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<h4 class="mat-h4">Paid alerts</h4>
|
||||
<div class="sms-checks">
|
||||
<mat-checkbox formControlName="repaymentAlert">Amount credited (deposit into the account)</mat-checkbox>
|
||||
<mat-checkbox formControlName="debitAlert">Amount debited (drawing / charge)</mat-checkbox>
|
||||
<mat-checkbox formControlName="chargeInterest">Interest and charges applied</mat-checkbox>
|
||||
@if (chequeBookOffered) {
|
||||
<mat-checkbox formControlName="chequeBook">Cheque book issued</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="layout-row-wrap layout-lt-md-column gap-2percent sms-amounts">
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Debit alert above (₹)</mat-label>
|
||||
<input matInput type="number" min="0" formControlName="debitAbove" />
|
||||
<mat-hint>Alert only for debits greater than this amount</mat-hint>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="flex-48">
|
||||
<mat-label>Credit alert above (₹)</mat-label>
|
||||
<input matInput type="number" min="0" formControlName="creditAbove" />
|
||||
<mat-hint>Alert only for credits greater than this amount</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<p class="sms-note">
|
||||
SMS activation date: <strong>{{ activationDate }}</strong>
|
||||
@if (form.getRawValue().smsCharge === 'yes') {
|
||||
· SMS charge date: <strong>{{ activationDate }}</strong>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="layout-row align-center gap-2percent margin-t responsive-column stepper-buttons">
|
||||
<button mat-raised-button matStepperPrevious>
|
||||
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
|
||||
Previous
|
||||
</button>
|
||||
<button mat-raised-button matStepperNext>
|
||||
Next
|
||||
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
-24
@@ -1,24 +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/.
|
||||
*/
|
||||
|
||||
.sms-checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 28px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sms-amounts {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sms-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user