Compare commits

..
Author SHA1 Message Date
keshav 8bf74afe24 Add CC accounts tracking, AML rule trial and branch approval fixes 2026-10-19 18:16:22 +05:30
niteshandClaude Opus 5.5 885b5897af feat(api-management): set up each CIC bureau as an API vendor
CIBIL, Experian, Equifax and CRIF are vendors under API Management (active =
whitelisted) and the Credit Bureau dashboard reads them from there; the
separate CIC whitelist and API mapping screens are removed. Mapping tables show
the value each row finds in the sample payload, and bureau cards link to their
set-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:50:27 +05:30
sonali f0b8f7f5f4 Merge branch 'feature-demo' of https://git.lincpay.tech/CBSNexora/cbsnexora into feature-demo 2026-10-19 15:41:08 +05:30
sonaliandClaude Sonnet 5 1bf441114e feat(npa): add Cash Credit (CC) Recovery Management tab
New tab under NPA > Reports > Recovery Management, dedicated to Cash
Credit accounts: Drawing Power vs outstanding, interest due/paid this
cycle, lifetime recovery, and a Days-Overdrawn/Status view (Regular /
Overdrawn / NPA) in place of the generic EMI-style SMA labels, matching
how a CC/OD book is actually monitored in real banks.

Status now checks NPA history first so an account doesn't show
"Regular" just because it's briefly back within DP (RBI upgrade norms
require full clearance, not a one-off snapshot).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 15:38:03 +05:30
niteshandClaude Sonnet 5.5 896e3fd7e8 fix(credit-bureau): keep customer enquiry list stable and add back navigation in alert detail
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:33:43 +05:30
anshulandClaude Opus 5.5 f9748036c3 feat(ai): loan eligibility demo screen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:19:16 +05:30
niteshandClaude Sonnet 5.5 7eac2dcf3b feat(credit-bureau): CIC enquiry-alert dashboard and generic API Management hub (static UI)
Replace CIBIL fetch screens with a dashboard of our customers' loan enquiries
at other lenders (no lender name), CIC whitelist/API mapping, and a Vendor ->
Work -> API set management UI (IN/OUT, SMS, WhatsApp, CIC) with full HTTP detail
and JSON/cURL/Postman/HAR import.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:13:56 +05:30
niteshandClaude Opus 5.5 1b70fb4534 feat(cic): Credit Bureau menu - CIBIL / CIC enquiry, consent, whitelist and API mapping
Separate top-nav menu with dashboard, new enquiry, enquiry history and report,
consent register, CIC whitelist & configuration, and API mapping with preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:18:32 +05:30
146 changed files with 13311 additions and 8877 deletions
@@ -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,
+3
View File
@@ -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 });
}
+10
View File
@@ -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 });
+778
View File
@@ -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.';
}
});
}
}
+6
View File
@@ -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">
+9 -59
View File
@@ -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(
([
@@ -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(() => {
+22 -5
View File
@@ -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>
-15
View File
@@ -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;
}
-15
View File
@@ -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
@@ -150,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();
@@ -400,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 &amp; 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 &amp; 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 &amp; 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';
}
}
+627
View File
@@ -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
+276
View File
@@ -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 {}
@@ -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>
@@ -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;
}
@@ -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: [
@@ -56,13 +56,12 @@
flex-shrink: 0;
}
// A ticked day or voucher, in the screen's own green rather than the shared sheet's blue.
.option-picked {
border-color: #0f766e !important;
background: #e6f4ee !important;
border-color: #2563eb !important;
background: #eff6ff !important;
.option-check {
color: #0f766e;
color: #2563eb;
}
}
@@ -94,76 +93,3 @@
border-radius: 6px;
background: #f8fafc;
}
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
// A step smaller than the sheet it shares, for this screen only: the shared sheet is sized in rem for
// Apply Additional Shares and is left alone, so the whole dialog is scaled here instead. Nothing in it
// is an overlay, so the scaling cannot throw a popup out of place.
.dialog-wrapper {
zoom: 0.88;
}
// ── The Daily Transaction screen's colours ───────────────────────────────────────────────────────────
// The shared sheet is indigo and blue, for Apply Additional Shares. This dialog opens from a screen
// drawn in green and teal, so it takes that palette here - the shared sheet is left as it is, and these
// rules only state what differs from it.
$page-teal: #0f766e;
$page-dark: #0f4f3c;
$page-soft: #e6f4ee;
$page-border: #b7ddd0;
.dialog-header {
background: linear-gradient(135deg, $page-dark 0%, $page-teal 70%, #1f7a5c 100%);
}
.sc-card {
border-color: $page-border;
}
.slot-step {
background: linear-gradient(135deg, $page-dark 0%, $page-teal 100%);
}
.slot-search {
border-color: $page-border;
background: $page-soft;
color: $page-teal;
&:hover {
background: $page-teal;
border-color: $page-teal;
color: #fff;
box-shadow: 0 2px 8px rgb(15 118 110 / 25%);
}
}
.option {
&:hover {
border-color: $page-teal;
background: $page-soft;
box-shadow: 0 2px 6px rgb(15 118 110 / 10%);
}
&:hover .option-radio {
border-color: $page-teal;
}
}
.option-info:hover {
color: $page-teal;
border-color: $page-teal;
background: $page-soft;
}
.gl-table tbody tr:hover {
background: $page-soft;
}
.dialog-actions [mat-flat-button][color='primary']:not([disabled]) {
background: linear-gradient(135deg, $page-teal 0%, $page-dark 100%) !important;
box-shadow: 0 2px 8px rgb(15 118 110 / 28%) !important;
&:hover {
box-shadow: 0 4px 14px rgb(15 118 110 / 40%) !important;
}
}
@@ -1,78 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="cd">
<div class="cd-head">
<span class="cd-head__ic"><i class="fas fa-list"></i></span>
<div class="cd-head__text">
<h2>Collections up to {{ data.dateText }}</h2>
<p>{{ data.agentName }} - written down and not yet handed in</p>
</div>
<button mat-stroked-button type="button" class="cd-refresh" (click)="refresh()">
<i class="fas fa-sync"></i> Refresh
</button>
<button type="button" class="cd-close" (click)="close()" aria-label="Close"><i class="fas fa-times"></i></button>
</div>
<div class="cd-body">
@if (data.loading()) {
<p class="cd-empty">Reading this agent's entries...</p>
} @else if (entries.length === 0) {
<p class="cd-empty">{{ data.agentName }} has nothing outstanding up to this date.</p>
} @else {
<div class="cd-table">
<div class="cd-row cd-row--head">
<span class="cd-date">Date</span>
<span class="cd-code">Party</span>
<span class="cd-name">Name</span>
<span class="cd-mode">Mode</span>
<span class="cd-amount">Amount</span>
</div>
@for (entry of pageEntries; track entry.id) {
<!-- Clicking a line closes this and shows that party on the screen. -->
<div class="cd-row" (click)="pick(entry)" title="Show this party's account">
<span class="cd-date">{{ entry.entryDate | dateFormat }}</span>
<span class="cd-code">{{ entry.partyCode }}</span>
<span class="cd-name">{{ entry.partyName }}</span>
<span class="cd-mode"
><span class="cd-chip">{{ entry.transactionMode }}</span>
@if (entry.advanceDays) {
<small>{{ entry.advanceDays }}d advance</small>
}
@if (entry.arrearsDays) {
<small>{{ entry.arrearsDays }}d previous</small>
}
</span>
<span class="cd-amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
<div class="cd-total">
<span>{{ entries.length }} {{ entries.length === 1 ? 'entry' : 'entries' }}</span>
<strong>{{ data.total() | number: '1.2-2' }}</strong>
</div>
<p class="cd-note">
The cash and its receipts are under Agent Tran. Click a line to see that customer's account.
</p>
}
</div>
@if (entries.length > 0) {
<mat-paginator
class="cd-paginator"
[length]="entries.length"
[pageSize]="pageSize"
[pageIndex]="pageIndex"
[pageSizeOptions]="pageSizeOptions"
(page)="onPage($event)"
showFirstLastButtons
></mat-paginator>
}
</div>
@@ -1,235 +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/.
*/
$primary: #0f766e;
$primary-dark: #0f4f3c;
$primary-light: #e6f4ee;
$border: #e2e8f0;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
:host {
display: block;
}
// Small type throughout: this is a list to read down, and a page of it should fit the window.
.cd {
display: flex;
flex-direction: column;
max-height: 86vh;
font-size: 12.5px;
color: $text-dark;
}
.cd-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid $border;
&__ic {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 30px;
height: 30px;
border-radius: 9px;
background: $primary-light;
color: $primary-dark;
font-size: 13px;
}
&__text {
flex: 1;
min-width: 0;
h2 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
p {
margin: 1px 0 0;
font-size: 11.5px;
color: $text-mid;
}
}
}
.cd-refresh {
height: 30px;
padding: 0 12px;
font-size: 12px;
color: $primary;
}
.cd-close {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: $text-mid;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
}
.cd-body {
flex: 1;
min-height: 0;
padding: 10px 16px 6px;
overflow-y: auto;
}
.cd-empty {
margin: 8px 0;
padding: 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 10px;
background: #f8fafc;
color: $text-mid;
text-align: center;
}
.cd-table {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
}
.cd-row {
display: grid;
grid-template-columns: 112px 78px minmax(0, 1fr) 120px 92px;
align-items: center;
gap: 10px;
padding: 7px 12px;
border-bottom: 1px solid #eef2f6;
cursor: pointer;
&:last-child {
border-bottom: 0;
}
&:hover {
background: #f0fdf9;
}
&--head {
cursor: default;
background: #f1f5f9;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
&:hover {
background: #f1f5f9;
}
}
}
.cd-date {
color: $text-mid;
font-variant-numeric: tabular-nums;
}
.cd-code {
font-weight: 700;
color: $primary-dark;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 12px;
}
.cd-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cd-mode {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
small {
font-size: 10.5px;
color: $text-mid;
}
}
.cd-chip {
padding: 1px 8px;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cd-amount {
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cd-total {
display: flex;
justify-content: flex-end;
align-items: baseline;
gap: 12px;
margin-top: 8px;
padding: 8px 12px;
border-radius: 8px;
background: $primary-light;
color: $text-mid;
strong {
font-size: 15px;
color: $primary-dark;
font-variant-numeric: tabular-nums;
}
}
.cd-note {
margin: 8px 2px 4px;
font-size: 11.5px;
color: $text-soft;
}
.cd-paginator {
flex: 0 0 auto;
border-top: 1px solid $border;
font-size: 12px;
--mat-paginator-container-text-size: 12px;
--mat-paginator-container-size: 44px;
--mat-paginator-form-field-container-height: 30px;
--mat-paginator-form-field-container-vertical-padding: 3px;
}
@media (width <= 640px) {
.cd-row {
grid-template-columns: 90px 70px minmax(0, 1fr) 80px;
.cd-mode {
display: none;
}
}
}
@@ -1,93 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { DateFormatPipe } from 'app/pipes/date-format.pipe';
/**
* What the dialog is given. The entries are read through functions rather than copied in, so a Refresh
* on the screen behind - which reloads the list - shows here as soon as it arrives.
*/
export interface CollectionsDialogData {
agentName: string;
/** The day the list runs up to, already worded for a reader. */
dateText: string;
entries: () => any[];
total: () => number;
loading: () => boolean;
refresh: () => void;
}
/**
* Everything the agent wrote down and has not handed in yet, up to the chosen day, customer by
* customer - the list the clerk counts a bag of cash against.
*
* It lives in a window, paged, rather than down the page: a round can run to hundreds of lines, and
* the screen behind it has a good deal more to show. A line can be clicked to see that customer's
* account on the screen; the dialog closes with the entry picked.
*/
@Component({
selector: 'mifosx-dds-collections-dialog',
standalone: true,
templateUrl: './collections-dialog.component.html',
styleUrls: ['./collections-dialog.component.scss'],
imports: [
CommonModule,
MatDialogModule,
MatButtonModule,
MatPaginatorModule,
DateFormatPipe
]
})
export class CollectionsDialogComponent {
readonly data = inject<CollectionsDialogData>(MAT_DIALOG_DATA);
private dialogRef = inject(MatDialogRef<CollectionsDialogComponent>);
readonly pageSizeOptions = [
10,
25,
50,
100
];
pageIndex = 0;
pageSize = 10;
get entries(): any[] {
return this.data.entries() || [];
}
/** The lines on the page being looked at. */
get pageEntries(): any[] {
const start = this.pageIndex * this.pageSize;
return this.entries.slice(start, start + this.pageSize);
}
onPage(event: PageEvent): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
}
refresh(): void {
// A reloaded list may be shorter than the page being looked at.
this.pageIndex = 0;
this.data.refresh();
}
/** Closes with the line clicked, for the screen to show whose account it is. */
pick(entry: any): void {
this.dialogRef.close(entry);
}
close(): void {
this.dialogRef.close();
}
}
@@ -11,20 +11,14 @@
<button mat-stroked-button [routerLink]="['../']" class="dte-back">
<i class="fas fa-arrow-left"></i> Deposits
</button>
<div class="dte-header__icon"><i class="fas fa-coins"></i></div>
<div class="dte-header__text">
<h2 class="dte-header__title">Daily Transaction Detail</h2>
<p class="dte-header__sub">
An agent's round - what was collected, the cash handed in and applied - and transfers between accounts.
</p>
</div>
<h2 class="dte-header__title">Daily Transaction Detail</h2>
</div>
<div class="dte-card">
<form [formGroup]="entryForm">
<div class="dte-info-panels">
<div class="dte-info-panel">
<div class="dte-info-panel__title"><i class="fas fa-user-tie"></i>Agent Information</div>
<div class="dte-info-panel__title">Agent Information</div>
<dl>
<dt>Name</dt>
<dd>{{ agentInfo.name || '—' }}</dd>
@@ -34,22 +28,17 @@
<dd>{{ agentInfo.city || '—' }}</dd>
<dt>A/C</dt>
<dd>{{ agentInfo.accountNo || '—' }}</dd>
<!-- One line for each guarantor the agent actually has - not two fixed lines, one of them empty. -->
@for (guarantor of agentInfo.guarantors; track $index; let i = $index) {
<dt>{{ agentInfo.guarantors.length > 1 ? 'Guarantor ' + (i + 1) : 'Guarantor' }}</dt>
<dd>{{ guarantor }}</dd>
} @empty {
<dt>Guarantor</dt>
<dd>—</dd>
}
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor1 || '—' }}</dd>
<dt>Guarantor</dt>
<dd>{{ agentInfo.guarantor2 || '—' }}</dd>
<dt>Curr Bal</dt>
<dd>{{ agentInfo.currentBalance | number: '1.2-2' }}</dd>
</dl>
</div>
<!-- Wider than the agent's card, with its details two pairs to a line, so a party's account is read without scrolling. -->
<div class="dte-info-panel dte-info-panel--party">
<div class="dte-info-panel__title"><i class="fas fa-user"></i>Party Information</div>
<div class="dte-info-panel">
<div class="dte-info-panel__title">Party Information</div>
@if (partyInfo) {
<dl>
<dt>Client Id</dt>
@@ -90,62 +79,15 @@
</dl>
} @else {
<p class="dte-info-panel__hint">
<i class="fas fa-info-circle"></i>
Click an entry in the collections list below — this panel shows whose account it is and what the balance
Pick a party in a collection row below — this panel shows whose account it is and what the balance
becomes.
</p>
}
</div>
</div>
<!-- The round at a glance, once an agent is picked: nothing here is typed or saved. -->
@if (agentInfo.name) {
<div class="dte-kpis">
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-users"></i>Parties</span>
<strong class="dte-kpi__value">{{ agentParties.length }}</strong>
<span class="dte-kpi__hint">to collect from</span>
</div>
@if (isPartyTran) {
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-list-ol"></i>Entries</span>
<strong class="dte-kpi__value">{{ pendingEntries.length }}</strong>
<span class="dte-kpi__hint">written down, not handed in</span>
</div>
<div class="dte-kpi dte-kpi--accent">
<span class="dte-kpi__label"><i class="fas fa-rupee-sign"></i>To count</span>
<strong class="dte-kpi__value">{{ pendingTotal | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">the agent should hand over</span>
</div>
}
<!-- Agent Tran: what the agent owes on everything written down, what the cashier has taken in, and what is left. -->
@if (isAgentTran) {
<div class="dte-kpi dte-kpi--owed">
<span class="dte-kpi__label"><i class="fas fa-file-invoice"></i>To take</span>
<strong class="dte-kpi__value">{{ agentOwed | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">written down, not handed in</span>
</div>
<div class="dte-kpi dte-kpi--received">
<span class="dte-kpi__label"><i class="fas fa-check-circle"></i>Received (approved)</span>
<strong class="dte-kpi__value">{{ agentReceived | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">taken in by the cashier</span>
</div>
<div class="dte-kpi" [class.dte-kpi--due]="agentBalance > 0">
<span class="dte-kpi__label"><i class="fas fa-hourglass-half"></i>Still to take</span>
<strong class="dte-kpi__value">{{ agentBalance | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">left to receive</span>
</div>
}
<div class="dte-kpi">
<span class="dte-kpi__label"><i class="fas fa-wallet"></i>Agent Balance</span>
<strong class="dte-kpi__value">{{ agentInfo.currentBalance | number: '1.2-2' }}</strong>
<span class="dte-kpi__hint">held by {{ agentInfo.name }}</span>
</div>
</div>
}
<div class="dte-controls">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field dte-field--agent">
<mat-form-field appearance="outline" class="dte-field dte-field--agent">
<mat-label>Agent</mat-label>
<mat-select required formControlName="agentId" (selectionChange)="loadAgent()">
@for (agent of selectableAgents; track agent.id) {
@@ -170,7 +112,7 @@
}
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Date</mat-label>
<!-- The date picks out a round, not just a posting date: a Party Tran settles whatever
the agent wrote down for that day, so changing it has to fetch that day's list. -->
@@ -185,7 +127,7 @@
<mat-datepicker #txnDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="dte-field">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Tran. Type</mat-label>
<mat-select required formControlName="transactionType">
@for (type of transactionTypes; track type.label) {
@@ -198,43 +140,100 @@
}
</mat-select>
</mat-form-field>
<!-- Agent Tran: the cashier's receipts are applied to the customers from here - the dialog shows the days and receipts. -->
@if (isAgentTran && agentInfo.name) {
<div class="dte-agent-actions">
<button mat-stroked-button type="button" class="dte-refresh-btn" (click)="loadAgentMoney()">
<i class="fas fa-sync"></i> Refresh
</button>
<button mat-raised-button color="primary" type="button" class="dte-apply-btn" (click)="openApplyReceipt()">
<i class="fas fa-receipt"></i> Apply DDS Receipt
</button>
</div>
}
<!-- The agent's written-down round, in a window of its own and paged. -->
@if (isPartyTran && agentInfo.name) {
<button mat-stroked-button type="button" class="dte-view-btn" (click)="openCollections()">
<i class="fas fa-list"></i> View Collections
<span class="dte-view-btn__count">{{ pendingEntries.length }}</span>
</button>
}
</div>
<!--
What the agent wrote down on their round, waiting to be counted, is no longer listed down this
page - a long round pushed everything else out of sight. It opens from View Collections above, in a
window of its own and paged, and a line clicked there shows that customer in Party Information.
What the agent wrote down on their round, waiting to be counted.
This used to be an empty grid the clerk typed the round into, and typing it posted it: a
customer was credited the moment a line was keyed, whether or not the cash behind it ever
reached the branch. The agent records their own collections now, and what the clerk does here
is agree with them - so there is nothing to type, only a list to check against a bag of cash.
-->
@if (isPartyTran && agentInfo.name) {
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-list"></i>
<h3>Collections up to {{ entryForm.get('transactionDate')?.value | dateFormat }}</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadPendingEntries()">
<i class="fas fa-sync"></i> Refresh
</button>
</div>
@if (pendingLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (pendingEntries.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding up to this date.</p>
} @else {
@for (entry of pendingEntries; track entry.id) {
<div class="dte-pending-row">
<!-- The day the agent took it, on the line itself. All the lines carry the date
chosen above, but a row that says so can be read, printed or shown to the agent
on its own without the header to explain it. -->
<span class="dte-pending-row__date">{{ entry.entryDate | dateFormat }}</span>
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
<div class="dte-total">
<span>{{ pendingEntries.length }} {{ pendingEntries.length === 1 ? 'entry' : 'entries' }}</span>
<strong>{{ pendingTotal | number: '1.2-2' }}</strong>
</div>
<!--
The figure the clerk counts against, said in words rather than left as a column total -
it is the one number they are acting on, and the rest of the panel is working out.
It says what the agent wrote down and stops there. The money side - what is owed, what the
cashier has taken in, and applying it - is Agent Tran, so each type has one job.
-->
<p class="dte-expected dte-expected--note">
Everything {{ agentInfo.name }} wrote down and has not handed in yet, up to this date, customer by
customer. The cash and its receipts are under Agent Tran.
</p>
}
</div>
}
<!--
The agent's money used to have a section of its own down the page: what they owe on everything
written down, what the cashier has taken in, what is left, then the outstanding days and the
cashier's receipts. Apply DDS Receipt shows the same days and receipts, so the section is gone -
the three figures sit in the summary above, the button is beside the Date, and what is below is
only what the dialog does not say: which GL the cash goes into and anything that looks wrong.
The agent's money: what they owe on everything written down, what the cashier has taken in
against it, and what is left.
This used to be a form the clerk typed an amount into to take the agent's cash in here. The
cash is taken at the cashier's counter now, into the agent's own GL account, and one way to take
the same cash in is the only safe number of ways - so there is nothing to type. What is done here
is applying the cashier's receipts to the customers, in Apply DDS Receipt.
-->
@if (isAgentTran && agentInfo.name) {
<div class="dte-notes">
<div class="dte-section">
<div class="dte-section__header">
<i class="fas fa-hand-holding-usd"></i>
<h3>Agent's cash</h3>
<button mat-stroked-button type="button" class="dte-add-row" (click)="loadAgentMoney()">
<i class="fas fa-sync"></i> Refresh
</button>
<button
mat-raised-button
color="primary"
type="button"
class="dte-apply-receipt"
(click)="openApplyReceipt()"
>
<i class="fas fa-receipt"></i> Apply DDS Receipt
</button>
</div>
@if (!agentGlCode) {
<p class="dte-expected dte-expected--note">
{{ agentInfo.name }} has no GL account, so the cashier has nowhere to take their cash. Set one on Agent
@@ -247,6 +246,21 @@
takes {{ agentInfo.name }}'s cash into this GL (Entry Type DDS).
</p>
}
<div class="dte-money">
<div class="dte-money__box">
<span class="dte-money__label">To take</span>
<strong>{{ agentOwed | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box">
<span class="dte-money__label">Received (approved)</span>
<strong>{{ agentReceived | number: '1.2-2' }}</strong>
</div>
<div class="dte-money__box" [class.dte-money__box--due]="agentBalance > 0">
<span class="dte-money__label">Still to take</span>
<strong>{{ agentBalance | number: '1.2-2' }}</strong>
</div>
</div>
@if (agentReceivedBeyondDay > 0) {
<p class="dte-expected dte-expected--note">
{{ agentReceivedBeyondDay | number: '1.2-2' }} received at the counter is more than everything outstanding
@@ -259,6 +273,66 @@
{{ agentWaiting | number: '1.2-2' }} more is at the counter awaiting the manager's approval.
</p>
}
<h4 class="dte-subhead">
Outstanding entries up to {{ entryForm.get('transactionDate')?.value | dateFormat }}
</h4>
@if (agentMoneyLoading) {
<p class="dte-empty">Reading this agent's entries...</p>
} @else if (agentDays.length === 0) {
<p class="dte-empty">{{ agentInfo.name }} has nothing outstanding.</p>
} @else {
@for (day of agentDays; track day.date) {
<div class="dte-pending-row dte-pending-row--day" (click)="toggleAgentDay(day.date)">
<span class="dte-pending-row__date">{{ day.date | dateFormat }}</span>
<span class="dte-pending-row__name">
{{ day.entries.length }} {{ day.entries.length === 1 ? 'entry' : 'entries' }}
</span>
<span class="dte-pending-row__amount">{{ day.total | number: '1.2-2' }}</span>
<i [class]="agentOpenDay === day.date ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
</div>
@if (agentOpenDay === day.date) {
<div class="dte-day-lines">
@for (entry of day.entries; track entry.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ entry.partyCode }}</span>
<span class="dte-pending-row__name">{{ entry.partyName }}</span>
<span class="dte-pending-row__mode"
>{{ entry.transactionMode }}
@if (entry.advanceDays) {
· {{ entry.advanceDays }}d advance
}
@if (entry.arrearsDays) {
· {{ entry.arrearsDays }}d previous
}
</span>
<span class="dte-pending-row__amount">{{ entry.amount | number: '1.2-2' }}</span>
</div>
}
</div>
}
}
}
<h4 class="dte-subhead">Cashier receipts</h4>
@if (agentReceipts.length === 0) {
<p class="dte-empty">
No DDS receipt on {{ agentInfo.name }}'s GL account{{ agentGlCode ? ' ' + agentGlCode : '' }} waiting to
be applied.
</p>
} @else {
@for (receipt of agentReceipts; track receipt.id) {
<div class="dte-pending-row">
<span class="dte-pending-row__code">{{ receipt.voucherNo }}</span>
<span class="dte-pending-row__date">{{ receipt.txnDate | dateFormat }}</span>
<span class="dte-pending-row__name">{{ receipt.cashierName || '—' }}</span>
<span class="dte-status" [class.dte-status--ok]="receipt.status === 'APPROVED'">
{{ receipt.status === 'APPROVED' ? 'Approved' : 'Awaiting manager' }}
</span>
<span class="dte-pending-row__amount">{{ receipt.amount | number: '1.2-2' }}</span>
</div>
}
}
</div>
}
@@ -268,102 +342,84 @@
<i class="fas fa-exchange-alt"></i>
<h3>Withdraw Deposite</h3>
</div>
<div class="dte-grid dte-grid--transfer">
<!-- The account the money goes into. -->
<div class="dte-party-card dte-party-card--to">
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-down"></i>Money goes into</div>
<div class="dte-party-card__fields">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<input
matInput
formControlName="creditorPartyCode"
[matAutocomplete]="creditorAuto"
#creditorTrigger="matAutocompleteTrigger"
(click)="creditorTrigger.openPanel()"
(focus)="creditorTrigger.openPanel()"
(blur)="settleTransferParty('creditor')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon
matSuffix
class="dte-list-arrow"
(click)="$event.stopPropagation(); creditorTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('creditor'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('creditor').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
<mat-hint>Account the money goes into</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Name</mat-label>
<input matInput formControlName="creditorPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field>
</div>
</div>
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<input
matInput
formControlName="creditorPartyCode"
[matAutocomplete]="creditorAuto"
#creditorTrigger="matAutocompleteTrigger"
(click)="creditorTrigger.openPanel()"
(focus)="creditorTrigger.openPanel()"
(blur)="settleTransferParty('creditor')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); creditorTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #creditorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('creditor'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('creditor').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
<mat-hint>Account the money goes into</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Name</mat-label>
<input matInput formControlName="creditorPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field>
<!-- The account the money is taken from. -->
<div class="dte-party-card dte-party-card--from">
<div class="dte-party-card__title"><i class="fas fa-arrow-circle-up"></i>Money is taken from</div>
<div class="dte-party-card__fields">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<input
matInput
formControlName="debitPartyCode"
[matAutocomplete]="debitAuto"
#debitTrigger="matAutocompleteTrigger"
(click)="debitTrigger.openPanel()"
(focus)="debitTrigger.openPanel()"
(blur)="settleTransferParty('debit')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon
matSuffix
class="dte-list-arrow"
(click)="$event.stopPropagation(); debitTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('debit'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('debit').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
@if (transferToItself) {
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
} @else {
<mat-hint>Account the money is taken from</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Name</mat-label>
<input matInput formControlName="debitPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" readonly />
</mat-form-field>
</div>
</div>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Code</mat-label>
<input
matInput
formControlName="debitPartyCode"
[matAutocomplete]="debitAuto"
#debitTrigger="matAutocompleteTrigger"
(click)="debitTrigger.openPanel()"
(focus)="debitTrigger.openPanel()"
(blur)="settleTransferParty('debit')"
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon matSuffix class="dte-list-arrow" (click)="$event.stopPropagation(); debitTrigger.openPanel()"
>arrow_drop_down</mat-icon
>
<mat-autocomplete #debitAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (party of transferPartyOptions('debit'); track party.id) {
<mat-option [value]="party.partyCode">
{{ party.partyCode }} · {{ party.partyName }} · ₹{{ party.currentBalance | number: '1.2-2' }}
</mat-option>
}
@if (transferPartyOptions('debit').length === 0) {
<mat-option disabled>No open DDS account matches</mat-option>
}
</mat-autocomplete>
@if (transferToItself) {
<mat-hint class="dte-same-party">Pick a different account - it cannot transfer to itself</mat-hint>
} @else {
<mat-hint>Account the money is taken from</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Party Name</mat-label>
<input matInput formControlName="debitPartyName" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Debit Curr Bal</mat-label>
<input matInput formControlName="debitPartyBalance" readonly />
</mat-form-field>
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Amount</mat-label>
File diff suppressed because it is too large Load Diff
@@ -19,7 +19,6 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf
/** Custom Components */
import { DayCollectionDialogComponent } from './day-collection-dialog/day-collection-dialog.component';
import { NoticeDialogComponent } from './notice-dialog/notice-dialog.component';
import { CollectionsDialogComponent } from './collections-dialog/collections-dialog.component';
import {
ApplyDdsReceiptDialogComponent,
EntryDay,
@@ -153,8 +152,6 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: '',
guarantor1: '',
guarantor2: '',
// Every guarantor the agent has, by name - the panel lists as many as there are rather than two fixed lines.
guarantors: [] as string[],
currentBalance: 0
};
@@ -508,34 +505,6 @@ export class DailyTransactionEntryComponent implements OnInit {
* agent still has outstanding. Its own screen, like Apply Additional Shares, so the list above stays
* what it is - what to take from the agent - and the applying is done in one deliberate place.
*/
/**
* Opens what the agent wrote down up to the chosen day, in a window of its own and paged - it used to
* run down the page, where a long round pushed everything else out of sight. A line clicked there
* shows that customer in the Party Information panel.
*/
openCollections(): void {
this.dialog
.open(CollectionsDialogComponent, {
width: '820px',
maxWidth: '96vw',
autoFocus: false,
data: {
agentName: this.agentInfo.name,
dateText: this.dateUtils.formatDate(this.entryForm.get('transactionDate')?.value, 'dd MMMM yyyy'),
entries: () => this.pendingEntries,
total: () => this.pendingTotal,
loading: () => this.pendingLoading,
refresh: () => this.loadPendingEntries()
}
})
.afterClosed()
.subscribe((entry: any) => {
if (entry) {
this.showPartyOfEntry(entry);
}
});
}
openApplyReceipt(): void {
const agentId = this.entryForm.get('agentId')?.value;
if (!agentId) {
@@ -544,7 +513,7 @@ export class DailyTransactionEntryComponent implements OnInit {
const picked = this.agents.find((agent: any) => agent.id === agentId);
this.dialog
.open(ApplyDdsReceiptDialogComponent, {
width: '700px',
width: '780px',
maxWidth: '96vw',
panelClass: 'apply-collection-dialog-panel',
data: {
@@ -830,7 +799,6 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: agent.agentSavingAccountNo || '',
guarantor1: guarantors[0]?.guarantorName || '',
guarantor2: guarantors[1]?.guarantorName || '',
guarantors: guarantors.map((guarantor: any) => guarantor?.guarantorName).filter((name: any) => !!name),
currentBalance: agent.agentCurrentBalance || 0
};
this.agentGlCode = agent.glAccountCode || null;
@@ -845,7 +813,6 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: '',
guarantor1: '',
guarantor2: '',
guarantors: [],
currentBalance: 0
};
}
@@ -923,29 +890,11 @@ export class DailyTransactionEntryComponent implements OnInit {
row.get('balanceAfterTransaction')?.setValue(current + amount, { emitEvent: false });
}
/**
* The entry in the collections list the Party Information panel is showing, if one was clicked. The
* agent writes the round down on their own screen now, so there is no row here to key a party into -
* the panel is filled from the line the clerk is looking at instead.
*/
selectedEntry: any = null;
/** Shows whose account a written-down entry is, and what its balance becomes with the entry on it. */
showPartyOfEntry(entry: any): void {
this.selectedEntry = entry;
this.activeRow = -1;
this.partyInfo = this.agentParties.find((party) => String(party.partyCode) === String(entry?.partyCode)) || null;
}
/** The balance the party panel shows - live against whatever the active row currently holds. */
get partyBalanceAfter(): number {
if (!this.partyInfo) {
if (!this.partyInfo || this.activeRow < 0) {
return 0;
}
if (this.activeRow < 0) {
// No keyed row: it is the entry picked from the list that is being added to the balance.
return Number(this.partyInfo.currentBalance || 0) + Number(this.selectedEntry?.amount || 0);
}
const row = this.collections.at(this.activeRow);
return Number(this.partyInfo.currentBalance || 0) + Number(row?.get('amount')?.value || 0);
}
@@ -6,8 +6,6 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */
.dcd-field {
width: 100%;
}
@@ -42,34 +40,3 @@
color: rgb(0 0 0 / 60%);
}
}
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
// The same step smaller as the rest of the screen it opens from.
:host {
--mat-form-field-container-height: 38px;
--mat-form-field-container-vertical-padding: 8px;
--mat-form-field-container-text-size: 13px;
--mat-form-field-container-text-line-height: 20px;
--mat-form-field-outlined-label-text-populated-size: 11px;
--mat-form-field-subscript-text-size: 11px;
--mat-select-trigger-text-size: 13px;
font-size: 13px;
}
/* stylelint-disable-next-line selector-no-qualifying-type */
h2[mat-dialog-title] {
font-size: 15px;
}
.dcd-amount {
padding: 8px 12px;
&__figure {
font-size: 1.1rem;
}
&__basis {
font-size: 0.74rem;
}
}
@@ -6,8 +6,6 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* stylelint-disable no-duplicate-selectors -- the compact-density pass below deliberately re-opens earlier rules; kept in cascade order */
.nd-head {
display: flex;
align-items: center;
@@ -44,22 +42,3 @@
line-height: 1.55;
color: rgb(0 0 0 / 70%);
}
// ── Compact density ──────────────────────────────────────────────────────────────────────────────────
.nd-head {
padding: 14px 20px 2px;
&__ic {
width: 30px;
height: 30px;
}
&__title {
font-size: 0.95rem;
}
}
.nd-message {
font-size: 0.82rem;
line-height: 1.5;
}
@@ -19,487 +19,72 @@
<span>Loading entry…</span>
</div>
} @else {
<div class="dri-body" [class.ddr-body]="isProductReview || isAgentReview || isPartyReview">
@if (isProductReview) {
<!--
A DDS product request reads as the product it creates or changes: who and what at the top,
the four terms a checker looks at first, then every term grouped as the Account Type screen
asks for them. An edit shows each changed term as old → new.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'edit_note' : 'savings' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
isEdit ? 'Edit request' : 'New product'
}}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('accountTypeDescription') || entry.details || 'DDS Product' }}</h2>
<div class="ddr-hero__code">
<span class="ddr-mono">{{ valueOf('accountTypeCode') || '—' }}</span>
@if (valueOf('accountTypeGroup')) {
<span class="ddr-dot"></span>
<span>{{ valueOf('accountTypeGroup') }} group</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
<div class="dri-body">
<!-- Header card: the voucher it moves under, its status, who made it and when -->
<div class="dri-card dri-card--header">
<div class="dri-card-header">
<div class="dri-card-header__left">
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
</span>
<span class="ddr-meta">
</div>
<div class="dri-card-header__meta">
<span class="dri-meta-item">
<mat-icon>person</mat-icon>
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
</span>
<span class="dri-meta-item">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
{{ formatDateTime(entry.madeOn) }}
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
<!-- The terms a checker reads first. -->
<section class="ddr-stats">
<div class="ddr-stat ddr-stat--accent">
<span class="ddr-stat__label"><mat-icon>percent</mat-icon>Interest Rate</span>
<span class="ddr-stat__value">{{ valueOf('interestRate') || '—' }}</span>
<span class="ddr-stat__hint">p.a., worked out daily</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event_repeat</mat-icon>Interest Posting</span>
<span class="ddr-stat__value">
{{ valueOf('autoInterestPost') === 'No' ? 'Manual' : valueOf('autoInterestPostWhen') || 'Monthly' }}
</span>
<span class="ddr-stat__hint">
{{ valueOf('autoInterestPost') === 'No' ? 'Posted from the batch screen' : 'Automatic, at period end' }}
</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>lock_clock</mat-icon>Lock-in</span>
<span class="ddr-stat__value">{{ valueOf('prematureLockinMonths') || '—' }}</span>
<span class="ddr-stat__hint">before early closure</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event_available</mat-icon>Maturity</span>
<span class="ddr-stat__value">{{
valueOf('maturityAutoClose') === 'Yes' ? 'Auto close' : 'Manual payout'
}}</span>
<span class="ddr-stat__hint">on the maturity date</span>
</div>
</section>
@if (compareFields) {
<div class="ddr-banner" [class.ddr-banner--none]="!changedCount">
<mat-icon>{{ changedCount ? 'compare_arrows' : 'info' }}</mat-icon>
@if (changedCount) {
<span>
<b>{{ changedCount }} term{{ changedCount !== 1 ? 's' : '' }}</b> will change on approval - highlighted
below as current → proposed.
</span>
} @else {
<span>This edit changes nothing - every term matches the product as it stands.</span>
}
</div>
}
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
@if (group.changed) {
<span class="ddr-chip ddr-chip--edit">{{ group.changed }} changed</span>
}
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile" [class.ddr-tile--changed]="field.changed">
<span class="ddr-tile__label">
{{ field.label }}
@if (field.changed) {
<span class="ddr-tile__flag">Changed</span>
}
</span>
@if (field.changed) {
<span class="ddr-tile__diff">
<span class="ddr-tile__old">{{ field.current || '—' }}</span>
<mat-icon>arrow_forward</mat-icon>
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
</span>
} @else {
<ng-container *ngTemplateOutlet="valueTpl; context: { field: field }"></ng-container>
}
</div>
}
</div>
</section>
}
<ng-template #valueTpl let-field="field">
@if (field.format === 'yesno' && field.value) {
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
{{ field.value }}
</span>
} @else {
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'accountTypeCode'">{{
field.value || '—'
}}</span>
}
</ng-template>
} @else if (isAgentReview) {
<!--
An agent request reads as the agent it opens or changes: who and when at the top, then the
client, address, branch and accounts - the branch, the ledger account and the client by name,
not number - and the guarantors named with it. An edit lists only what it changes.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'manage_accounts' : 'person_add' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{ isEdit ? 'Edit request' : 'New agent' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('agentNameId') || entry.details || 'DDS Agent' }}</h2>
<div class="ddr-hero__code">
@if (valueOf('clientId')) {
<span>Client {{ valueOf('clientId') }}</span>
}
@if (valueOf('agentSavingAccountNo')) {
<span class="ddr-dot"></span>
<span class="ddr-mono">{{ valueOf('agentSavingAccountNo') }}</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile">
<span class="ddr-tile__label">{{ field.label }}</span>
<span class="ddr-tile__value" [class.ddr-mono]="field.key === 'agentSavingAccountNo'">{{
field.value || '—'
}}</span>
</div>
}
</div>
</section>
}
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"><mat-icon>shield_person</mat-icon></span>
<span class="ddr-section__title">Guarantors</span>
<span class="ddr-chip ddr-chip--edit">{{ guarantors.length }}</span>
</header>
@if (guarantors.length) {
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Client</th>
<th>Name</th>
<th>Telephone</th>
<th>Address</th>
</tr>
</thead>
<tbody>
@for (g of guarantors; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td>{{ g.client || '—' }}</td>
<td>{{ g.name || '—' }}</td>
<td class="cell-mono">{{ g.telephone || '—' }}</td>
<td>{{ g.address || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="ddr-tile">
<span class="ddr-tile__value">No guarantors named in this request.</span>
</div>
}
</section>
} @else if (isPartyReview) {
<!--
A DDS account request reads as the account it opens or changes: who it is for and when, the
terms a checker looks at first, then every detail in the sections the Party Master Entry screen
has - the client, scheme, agent and branch by name, not number - and the nominees named with it.
An edit lists only what it changes.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'edit_note' : 'account_balance_wallet' }}</mat-icon>
</div>
<div class="ddr-hero__main">
<div class="ddr-hero__eyebrow">
<span class="ddr-chip" [class.ddr-chip--edit]="isEdit">{{
isEdit ? 'Edit request' : 'New DDS account'
}}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('partyName') || entry.details || 'DDS Account' }}</h2>
<div class="ddr-hero__code">
@if (valueOf('subType')) {
<span>{{ valueOf('subType') }}</span>
}
@if (valueOf('clientId')) {
<span class="ddr-dot"></span>
<span>Client {{ valueOf('clientId') }}</span>
}
</div>
</div>
<div class="ddr-hero__meta">
<span class="ddr-meta">
<mat-icon>person</mat-icon>
<span>
<b>{{ entry.makerName || entry.maker }}</b>
<small>{{ entry.maker }}</small>
</span>
</span>
<span class="ddr-meta">
<mat-icon>schedule</mat-icon>
<span>
<b>{{ formatDateTime(entry.madeOn) }}</b>
<small>Submitted</small>
</span>
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="ddr-meta">
<mat-icon>verified_user</mat-icon>
<span>
<b>{{ entry.checkerName || entry.checker }}</b>
<small>{{ entry.status === 'APPROVED' ? 'Approved by' : 'Rejected by' }}</small>
</span>
</span>
}
</div>
</section>
<!-- The terms a checker reads first. -->
<section class="ddr-stats">
<div class="ddr-stat ddr-stat--accent">
<span class="ddr-stat__label"><mat-icon>payments</mat-icon>Daily Deposit</span>
<span class="ddr-stat__value">{{ valueOf('dailyDepositAmount') || '—' }}</span>
<span class="ddr-stat__hint">collected every day</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>date_range</mat-icon>Term</span>
<span class="ddr-stat__value">{{ valueOf('days') ? valueOf('days') + ' days' : '—' }}</span>
<span class="ddr-stat__hint">{{ valueOf('month') ? valueOf('month') + ' months' : '' }}</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>event</mat-icon>Opening Date</span>
<span class="ddr-stat__value">{{ valueOf('openingDate') || '—' }}</span>
<span class="ddr-stat__hint">{{
valueOf('maturityDate') ? 'Matures ' + valueOf('maturityDate') : 'account opens'
}}</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>support_agent</mat-icon>Agent</span>
<span class="ddr-stat__value ddr-stat__value--small">{{ valueOf('agentId') || '—' }}</span>
<span class="ddr-stat__hint">{{ valueOf('officeId') || '' }}</span>
</div>
</section>
@for (group of fieldGroups; track group.name) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"
><mat-icon>{{ group.icon }}</mat-icon></span
>
<span class="ddr-section__title">{{ group.name }}</span>
</header>
<div class="ddr-grid">
@for (field of group.fields; track field.key) {
<div class="ddr-tile">
<span class="ddr-tile__label">{{ field.label }}</span>
@if (field.format === 'yesno') {
<span class="ddr-pill" [class.ddr-pill--yes]="field.value === 'Yes'">
<mat-icon>{{ field.value === 'Yes' ? 'check_circle' : 'remove_circle_outline' }}</mat-icon>
{{ field.value }}
</span>
} @else {
<span class="ddr-tile__value">{{ field.value || '—' }}</span>
}
</div>
}
</div>
</section>
}
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"><mat-icon>family_restroom</mat-icon></span>
<span class="ddr-section__title">Nominees</span>
@if (detail?.nomineeSplit && nominees.length) {
<span class="ddr-chip ddr-chip--edit">Split by {{ detail.nomineeSplit }}</span>
}
<span class="ddr-chip ddr-chip--edit">{{ nominees.length }}</span>
</header>
@if (nominees.length) {
<table class="dri-cr-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Relation</th>
<th>Gender</th>
<th>Mobile</th>
<th>Birth Date</th>
<th>Guardian</th>
<th class="num">Share</th>
</tr>
</thead>
<tbody>
@for (n of nominees; track $index; let i = $index) {
<tr>
<td class="dri-cr-idx">{{ i + 1 }}</td>
<td>{{ n.name || '—' }}</td>
<td>{{ n.relation || '—' }}</td>
<td>{{ n.gender || '—' }}</td>
<td class="cell-mono">{{ n.mobileNo || '—' }}</td>
<td>{{ n.birthDate || '—' }}</td>
<td>{{ n.guardianName || '—' }}</td>
<td class="num">{{ n.share || '—' }}</td>
</tr>
}
</tbody>
</table>
} @else {
<div class="ddr-tile">
<span class="ddr-tile__value">No nominee named in this request.</span>
</div>
}
</section>
} @else {
<!-- Header card: the voucher it moves under, its status, who made it and when -->
<div class="dri-card dri-card--header">
<div class="dri-card-header">
<div class="dri-card-header__left">
<!-- No voucher slot for a change that moves no money - it would only ever read "—". -->
@if (voucherNo || movesMoney) {
<span class="dri-voucher">{{ voucherNo || '—' }}</span>
}
<span class="dri-status" [ngClass]="statusClass(entry.status)">
{{ entry.status }}{{ entry.voucherCancelled ? ' · VOUCHER CANCELLED' : '' }}
</span>
</div>
<div class="dri-card-header__meta">
<span class="dri-meta-item">
<mat-icon>person</mat-icon>
{{ entry.makerName || entry.maker }} ({{ entry.maker }})
<mat-icon>check_circle</mat-icon>
Actioned by: {{ entry.checkerName || entry.checker }}
</span>
<span class="dri-meta-item">
<mat-icon>schedule</mat-icon>
{{ formatDateTime(entry.madeOn) }}
</span>
@if (entry.status !== 'PENDING' && entry.checker) {
<span class="dri-meta-item">
<mat-icon>check_circle</mat-icon>
Actioned by: {{ entry.checkerName || entry.checker }}
</span>
}
</div>
}
</div>
</div>
</div>
<!-- Summary -->
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>swap_horiz</mat-icon>
{{ entry.type }} Summary
<!-- Summary -->
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>swap_horiz</mat-icon>
{{ entry.type }} Summary
</div>
<div class="dri-detail-grid">
<div class="dri-detail-item">
<span class="dri-detail-label">Total Amount</span>
<span class="dri-detail-value dri-amount">
{{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }}
</span>
</div>
<div class="dri-detail-grid">
<!-- Amount and sides only for an entry that moves money; a product or agent change has neither. -->
@if (movesMoney) {
<div class="dri-detail-item">
<span class="dri-detail-label">Total Amount</span>
<span class="dri-detail-value dri-amount">
{{
entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—'
}}
</span>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span>
<span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span>
<span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
>
</div>
}
<div class="dri-detail-item">
<span class="dri-detail-label">DR Accounts</span>
<span class="dri-detail-value"
>{{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item">
<span class="dri-detail-label">CR Accounts</span>
<span class="dri-detail-value"
>{{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }}</span
>
</div>
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Details</span>
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
</div>
@if (detail?.description) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Details</span>
<span class="dri-detail-value">{{ entry.details || '—' }}</span>
<span class="dri-detail-label">Description</span>
<span class="dri-detail-value">{{ detail.description }}</span>
</div>
@if (detail?.description) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Description</span>
<span class="dri-detail-value">{{ detail.description }}</span>
</div>
}
</div>
}
</div>
}
</div>
<!-- Debit entries -->
@if (debitLines.length) {
@@ -640,7 +225,7 @@
}
@if (entry.status === 'PENDING' && !done) {
<div class="dri-actions" [class.ddr-actions]="isProductReview || isAgentReview || isPartyReview">
<div class="dri-actions">
@if (!confirmReject) {
<button type="button" class="dri-btn dri-btn--approve" [disabled]="!canAct" (click)="act('approve')">
<mat-icon>check_circle</mat-icon>
@@ -1,453 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
// The DDS product review. Layered on the Disbursement & Transfers sheet and drawn from the same
// palette, so it reads as part of the same Approval Center rather than a screen of its own.
$brand: #0f4f3c;
$brand-mid: #1f7a5c;
$brand-light: #e6f4f0;
$primary: #4a6cf7;
$warning: #b45309;
$warning-bg: #fffbeb;
$warning-border: #fde68a;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
$border: #e2e8f0;
$bg-white: #fff;
$bg-soft: #f8fafc;
$radius: 14px;
$shadow-sm:
0 1px 3px rgb(15 23 42 / 6%),
0 1px 2px rgb(15 23 42 / 4%);
@mixin icon($size) {
font-size: $size;
width: $size;
height: $size;
line-height: $size;
}
.ddr-mono {
font-family: 'JetBrains Mono', SFMono-Regular, Consolas, monospace;
letter-spacing: 0.02em;
}
// A product review has more to lay out than a money movement, so the dialog widens for it.
.dri-body.ddr-body {
max-width: none;
gap: 16px;
padding-bottom: 0;
}
.dri--dialog:has(.ddr-body) {
width: min(880px, 94vw);
}
// ── Hero ─────────────────────────────────────────────────────────────────────
.ddr-hero {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 18px;
align-items: center;
padding: 22px 24px;
border-radius: $radius;
background:
radial-gradient(circle at 100% 0%, rgb(255 255 255 / 12%) 0, transparent 45%),
linear-gradient(135deg, $brand 0%, $brand-mid 100%);
color: #fff;
box-shadow: 0 8px 24px rgb(15 79 60 / 22%);
&__icon {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: rgb(255 255 255 / 14%);
border: 1px solid rgb(255 255 255 / 22%);
mat-icon {
@include icon(28px);
}
}
&__main {
min-width: 0;
}
&__eyebrow {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
&__title {
margin: 0;
font-size: 21px;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
}
&__code {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 13px;
color: rgb(255 255 255 / 78%);
}
&__meta {
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 18px;
border-left: 1px solid rgb(255 255 255 / 18%);
}
// Status reads on the dark band as a light chip, whatever its colour on a white card.
.dri-status {
background: rgb(255 255 255 / 92%);
font-size: 11px;
padding: 3px 10px;
}
}
.ddr-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentcolor;
opacity: 0.6;
}
.ddr-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
mat-icon {
@include icon(18px);
opacity: 0.75;
}
b {
display: block;
font-weight: 600;
white-space: nowrap;
}
small {
display: block;
font-size: 11px;
color: rgb(255 255 255 / 65%);
}
}
.ddr-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgb(255 255 255 / 16%);
color: #fff;
border: 1px solid rgb(255 255 255 / 28%);
&--edit {
background: $warning-bg;
color: $warning;
border-color: $warning-border;
}
}
// ── Key terms ────────────────────────────────────────────────────────────────
.ddr-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.ddr-stat {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
background: $bg-white;
border: 1px solid $border;
border-radius: 12px;
box-shadow: $shadow-sm;
&__label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
mat-icon {
@include icon(16px);
color: $brand-mid;
}
}
&__value {
font-size: 18px;
font-weight: 700;
color: $text-dark;
overflow-wrap: anywhere;
}
&__hint {
font-size: 11.5px;
color: $text-mid;
min-height: 14px;
}
// A value that is a name rather than a figure - an agent's code and name - reads smaller.
&__value--small {
font-size: 14px;
line-height: 1.3;
}
&--accent {
background: $brand-light;
border-color: #b7ddd0;
.ddr-stat__value {
font-size: 26px;
color: $brand;
}
}
}
// ── Edit banner ──────────────────────────────────────────────────────────────
.ddr-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-radius: 12px;
font-size: 13px;
background: $warning-bg;
border: 1px solid $warning-border;
color: $warning;
mat-icon {
@include icon(20px);
flex-shrink: 0;
}
&--none {
background: $bg-soft;
border-color: $border;
color: $text-mid;
}
}
// ── Sections ─────────────────────────────────────────────────────────────────
.ddr-section {
background: $bg-white;
border: 1px solid $border;
border-radius: $radius;
box-shadow: $shadow-sm;
overflow: hidden;
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-bottom: 1px solid $border;
background: linear-gradient(90deg, $bg-soft 0%, $bg-white 100%);
}
&__icon {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: $brand-light;
color: $brand;
mat-icon {
@include icon(17px);
}
}
&__title {
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: $text-mid;
margin-right: auto;
}
}
.ddr-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ddr-tile {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-bottom: 1px solid $border;
min-width: 0;
// Two columns: a hairline between them, and none under the last row.
&:nth-child(odd) {
border-right: 1px solid $border;
}
&:last-child,
&:nth-last-child(2):nth-child(odd) {
border-bottom: 0;
}
&__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
color: $text-soft;
}
&__value {
font-size: 15px;
font-weight: 600;
color: $text-dark;
overflow-wrap: anywhere;
}
&__flag {
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
background: $warning-bg;
color: $warning;
border: 1px solid $warning-border;
}
&__diff {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
mat-icon {
@include icon(16px);
color: $warning;
}
}
&__old {
font-size: 14px;
color: $text-soft;
text-decoration: line-through;
}
&--changed {
background: linear-gradient(90deg, rgb(253 230 138 / 22%) 0%, $bg-white 85%);
box-shadow: inset 3px 0 0 #f59e0b;
}
}
.ddr-pill {
display: inline-flex;
align-items: center;
gap: 5px;
width: fit-content;
padding: 3px 10px 3px 7px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
background: #f1f5f9;
color: $text-mid;
mat-icon {
@include icon(16px);
}
&--yes {
background: #dcfce7;
color: #15803d;
}
}
// ── Actions stay in reach however far the terms run ─────────────────────────
.ddr-actions {
position: sticky;
bottom: 0;
z-index: 2;
margin: 4px -20px 0;
padding: 14px 20px;
background: rgb(255 255 255 / 94%);
backdrop-filter: blur(6px);
border-top: 1px solid $border;
box-shadow: 0 -6px 16px rgb(15 23 42 / 6%);
}
// ── Narrow screens ───────────────────────────────────────────────────────────
/* stylelint-disable-next-line media-feature-range-notation -- Safari compatibility */
@media (max-width: 720px) {
.ddr-hero {
grid-template-columns: auto 1fr;
&__meta {
grid-column: 1 / -1;
flex-flow: row wrap;
gap: 14px;
padding: 12px 0 0;
border-left: 0;
border-top: 1px solid rgb(255 255 255 / 18%);
}
}
.ddr-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ddr-grid {
grid-template-columns: 1fr;
}
.ddr-tile {
&:nth-child(odd) {
border-right: 0;
}
&:nth-last-child(2):nth-child(odd) {
border-bottom: 1px solid $border;
}
}
}
@@ -25,10 +25,7 @@ import { OrganizationService } from 'app/organization/organization.service';
@Component({
selector: 'mifosx-dds-approval-review',
templateUrl: './dds-approval-review.component.html',
styleUrls: [
'../../../clerk/disbursement-review/disbursement-review.component.scss',
'./dds-approval-review.component.scss'
],
styleUrls: ['../../../clerk/disbursement-review/disbursement-review.component.scss'],
standalone: true,
imports: [
CommonModule,
@@ -79,99 +76,6 @@ export class DdsApprovalReviewComponent implements OnInit {
return (this.detail?.lines || []).filter((line: any) => line.side === 'CR');
}
/** A product change's terms, field by field - empty for an entry that moves money. */
get fields(): any[] {
return this.detail?.fields || [];
}
/** An edit still waiting is shown against the product as it stands today. */
get compareFields(): boolean {
return this.fields.some((field: any) => 'current' in field);
}
/** A DDS product request, which is laid out as the product rather than as a money movement. */
get isProductReview(): boolean {
return this.entry?.entity === 'DDSACCOUNTTYPE' && this.fields.length > 0;
}
/** A DDS agent request, laid out as the agent and their guarantors rather than as a money movement. */
get isAgentReview(): boolean {
return this.entry?.entity === 'AGENT' && this.fields.length > 0;
}
/** A DDS account request, laid out as the account and its nominees rather than as a money movement. */
get isPartyReview(): boolean {
return this.entry?.entity === 'PARTY' && this.fields.length > 0;
}
/** The nominees a DDS account request names. */
get nominees(): any[] {
return this.detail?.nominees || [];
}
/** The guarantors an agent request names. */
get guarantors(): any[] {
return this.detail?.guarantors || [];
}
get isEdit(): boolean {
return this.entry?.action === 'UPDATE';
}
/** A term's value as it will stand once approved, by its request parameter name. */
valueOf(key: string): string {
return this.fields.find((field: any) => field.key === key)?.value || '';
}
get changedCount(): number {
return this.fields.filter((field: any) => field.changed).length;
}
private static readonly GROUP_ICONS: Record<string, string> = {
Product: 'inventory_2',
Interest: 'trending_up',
'Maturity & Premature Closure': 'event_busy',
'Customer & Scheme': 'groups',
'Personal & Contact': 'contact_page',
'Minor / Guardian': 'escalator_warning',
'Scheme Terms': 'event_note',
'Balances & Status': 'toggle_on',
'Maturity Payout': 'currency_exchange',
Agent: 'badge',
'Address & Contact': 'location_on',
'Branch & Accounts': 'account_balance',
Balances: 'balance'
};
/** The terms in the sections the server groups them into, in the order it sends them. */
get fieldGroups(): { name: string; icon: string; changed: number; fields: any[] }[] {
if (this.groupsFor !== this.fields) {
const groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
for (const field of this.fields) {
const name = field.group || 'Details';
let group = groups.find((g) => g.name === name);
if (!group) {
group = { name, icon: DdsApprovalReviewComponent.GROUP_ICONS[name] || 'tune', changed: 0, fields: [] };
groups.push(group);
}
group.fields.push(field);
group.changed += field.changed ? 1 : 0;
}
this.groupsFor = this.fields;
this.groups = groups;
}
return this.groups;
}
/** Built once per load rather than on every change detection pass. */
private groupsFor: any[] | null = null;
private groups: { name: string; icon: string; changed: number; fields: any[] }[] = [];
/** Whether the entry moves money at all, and so has an amount and debit and credit sides to show. */
get movesMoney(): boolean {
return (this.entry?.amount !== null && this.entry?.amount !== undefined) || !!this.detail?.lines?.length;
}
total(lines: any[]): number {
return lines.reduce((sum: number, line: any) => sum + Number(line.amount || 0), 0);
}
@@ -21,7 +21,6 @@ import { OrganizationService } from 'app/organization/organization.service';
import { SettingsService } from 'app/settings/settings.service';
import { AuthenticationService } from 'app/core/authentication/authentication.service';
import { DdsApprovalReviewComponent } from './dds-approval-review/dds-approval-review.component';
import { isDdsRoutedRequest } from 'app/tasks/product-entities';
/**
* Approval Center › DDS Approvals: every DDS entry waiting on a checker, in the clerk's terms - what
@@ -105,11 +104,7 @@ export class DdsApprovalsComponent implements OnInit {
.getDdsApprovals(this.filterStatus, this.isoDate(this.fromDate), this.isoDate(this.toDate))
.pipe(catchError(() => of([])))
.subscribe((rows: any) => {
// Pending DDS product and account requests wait under Product Approval and Pending Account
// instead; once actioned they show here again, so the DDS history stays in one place.
this.entries = (Array.isArray(rows) ? rows : []).filter(
(row: any) => this.filterStatus !== 'PENDING' || !isDdsRoutedRequest(row?.entity, row?.action)
);
this.entries = Array.isArray(rows) ? rows : [];
this.pageIndex = 0;
this.loading = false;
this.loaded = true;
@@ -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.
@@ -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;
}
@@ -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;
}
}
@@ -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>
@@ -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;
}
@@ -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>
@@ -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
@@ -11,17 +11,7 @@
<button mat-stroked-button [routerLink]="['/accounts/dds']" class="pm-back">
<i class="fas fa-arrow-left"></i> Party Master
</button>
<div class="pm-header__icon"><i class="fas fa-users"></i></div>
<div class="pm-header__text">
<h2 class="pm-header__title">{{ partyId ? 'Edit Party' : 'Party Master Entry' }}</h2>
<p class="pm-header__sub">
{{
partyId
? 'Update this daily deposit party.'
: 'Open a daily deposit party: who it is for, the scheme it runs under and the agent who collects.'
}}
</p>
</div>
<h2 class="pm-header__title">{{ partyId ? 'Edit Party' : 'Party Master Entry' }}</h2>
@if (isClosed) {
<span class="pm-closed-badge">Closed — view only</span>
}
@@ -30,13 +20,7 @@
<div class="pm-card">
<form [formGroup]="partyForm">
<mat-tab-group class="pm-tabs" [(selectedIndex)]="selectedTabIndex" animationDuration="200ms">
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-id-card pm-tab-icon"></i>Customer &amp; Scheme
@if (tabHasError('customer')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<mat-tab label="Main Detail">
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
@@ -54,27 +38,35 @@
matInput
required
formControlName="clientId"
(keydown.enter)="$event.preventDefault(); openClientSearch()"
placeholder="Client ID, or search by name or mobile number…"
[matAutocomplete]="clientAuto"
(focus)="onClientFocus()"
placeholder="Click to see customers, or type a name or ID…"
autocomplete="off"
/>
<!-- Opens Search Customer; picking a customer puts them here, which fills the rest. -->
<button
matSuffix
type="button"
class="pm-search-btn"
aria-label="Search customer"
matTooltip="Search by ID, name or mobile number"
[disabled]="partyForm.get('clientId')?.disabled"
(click)="$event.stopPropagation(); openClientSearch()"
<fa-icon matSuffix icon="search" class="pm-search-ic"></fa-icon>
<mat-autocomplete
#clientAuto="matAutocomplete"
panelWidth="min(30rem, 92vw)"
[displayWith]="displayClient"
>
<i class="fas fa-search"></i>
</button>
@if (clientNeedsSearch) {
<mat-hint class="pm-hint--missing"
>Press search to find this client by name or mobile number</mat-hint
>
} @else if (loadedClientName) {
@for (client of clientOptions; track client.id) {
<mat-option [value]="client">
<div class="pm-opt">
<span class="pm-opt-id">{{ client.id }}</span>
<span class="pm-opt-main">{{ client.displayName }}</span>
<span class="pm-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
<!-- Says so when the list is only the first page, so a customer missing from
it reads as "keep typing" rather than "not a customer here". -->
@if (clientListIsPartial) {
<mat-option disabled class="pm-opt-more">
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
</mat-option>
}
</mat-autocomplete>
@if (loadedClientName) {
<mat-hint class="pm-hint--found">{{ loadedClientName }}</mat-hint>
} @else if (clientLookupFailed) {
<mat-hint class="pm-hint--missing">No client with this ID</mat-hint>
@@ -236,28 +228,18 @@
</div>
}
<!-- Not a field: the server gives the code when the party is saved. A new party is shown the
code it is expected to get; one already on the books shows the code it has. -->
<div class="pm-f">
<label class="pm-f-lbl">Party Code</label>
<div class="pm-code">
<span class="pm-code__value">{{
(partyId ? partyForm.get('partyCode')?.value : expectedPartyCode) || '—'
}}</span>
<span class="pm-code__hint">{{
partyId ? 'Assigned when this party was opened' : 'Expected - assigned when saved'
}}</span>
</div>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="partyCode" placeholder="Auto-generated" />
</mat-form-field>
</div>
<!-- Not a field: the number of the DDS account opened most recently, for reference. Read from
the books and never saved. -->
<div class="pm-f">
<label class="pm-f-lbl">Last A/c</label>
<div class="pm-code">
<span class="pm-code__value">{{ lastAccountNo || '—' }}</span>
<span class="pm-code__hint">Latest DDS account opened</span>
</div>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="lastAccount" placeholder="—" />
</mat-form-field>
</div>
<div class="pm-f">
@@ -269,27 +251,7 @@
</mat-select>
</mat-form-field>
</div>
</div>
</div>
</div>
</mat-tab>
<!-- The same section, set out over its own tab: who the party is, where, and how to reach them. -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-address-card pm-tab-icon"></i>Personal &amp; Contact
@if (tabHasError('personal')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-address-card"></i>
<h3>Personal &amp; Contact</h3>
<span class="pm-section__sub">Name, address, contact and identity - filled from the client</span>
</div>
<div class="pm-grid">
<div class="pm-f pm-f--wide">
<label class="pm-f-lbl">Party Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
@@ -465,17 +427,7 @@
</div>
</div>
</div>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-child pm-tab-icon"></i>Minor / Guardian
@if (tabHasError('minor')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-user-friends"></i>
@@ -567,17 +519,7 @@
<p class="pm-note">An adult's account needs no guardian details.</p>
}
</div>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-calendar-day pm-tab-icon"></i>Scheme Terms
@if (tabHasError('scheme')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-calendar-day"></i>
@@ -678,17 +620,7 @@
<!-- Withdraw Type is decided when the account is paid out, not when it is opened. -->
</div>
</div>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-balance-scale pm-tab-icon"></i>Balances &amp; Status
@if (tabHasError('balances')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exclamation-triangle"></i>
@@ -829,19 +761,8 @@
<!-- Adv Code is decided when the account is paid out, not when it is opened. -->
</div>
</div>
</div>
</mat-tab>
<!-- Only when the account is paid out into a savings account - as the section always was. -->
@if (creditToRequired) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-exchange-alt pm-tab-icon"></i>Maturity Payout
@if (tabHasError('payout')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="pm-tab-content">
@if (creditToRequired) {
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exchange-alt"></i>
@@ -900,30 +821,15 @@
</div>
</div>
</div>
</div>
</mat-tab>
}
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-user-friends pm-tab-icon"></i>Nominee's Detail
@if (tabHasError('nominee')) {
<i class="fas fa-exclamation-circle pm-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
</div>
</mat-tab>
<mat-tab label="Nominee's Detail">
<div class="pm-tab-content">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-user-friends"></i>
<h3>Nominee's Detail</h3>
<span class="pm-section__sub"
>Who the deposit is paid to if the account holder is not there to claim it</span
>
</div>
<!-- Whether there are nominees, how the deposit is split between them, and how it adds up. -->
<div class="pm-nom-bar">
<div class="pm-f pm-nom-bar__applicable">
<div class="pm-grid">
<div class="pm-f">
<label class="pm-f-lbl">Nominee</label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select formControlName="nomineeApplicable">
@@ -934,172 +840,139 @@
</div>
@if (nomineesRequired) {
<div class="pm-f pm-nom-bar__mode">
<label class="pm-f-lbl">Split by</label>
<mat-radio-group formControlName="nomineeSplitMode" class="pm-nom-mode">
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
</mat-radio-group>
</div>
<mat-radio-group formControlName="nomineeSplitMode" class="pm-radio-group">
<mat-radio-button value="Percentage">Percentage</mat-radio-button>
<mat-radio-button value="Sequence">Sequence</mat-radio-button>
</mat-radio-group>
@if (splitByPercentage && nominees.length > 0) {
<div class="pm-nom-total" [class.pm-nom-total--bad]="nomineeShareError">
<div class="pm-nom-total__text">
<strong>{{ nomineeShareTotal }}%</strong> of 100% allocated
@if (nomineeShareError) {
<span class="pm-nom-total__error">— {{ nomineeShareError }}</span>
}
</div>
<div class="pm-nom-total__track">
<div
class="pm-nom-total__fill"
[class.pm-nom-total__fill--done]="nomineeShareTotal === 100"
[style.width.%]="nomineeShareTotal > 100 ? 100 : nomineeShareTotal"
></div>
</div>
</div>
} @else if (splitBySequence && nominees.length > 0) {
<div class="pm-nom-total" [class.pm-nom-total--bad]="nomineeOrderError">
<div class="pm-nom-total__text">
@if (nomineeOrderError) {
{{ nomineeOrderError }}
} @else {
Paid in the order numbered, 1 first
}
</div>
</div>
}
<button mat-stroked-button type="button" class="pm-nom-add" (click)="addNominee()">
<button mat-stroked-button type="button" class="pm-add-nominee" (click)="addNominee()">
<i class="fas fa-plus"></i> Add Nominee
</button>
@if (splitByPercentage && nominees.length > 0) {
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeShareError">
{{ nomineeShareTotal }}% of 100% allocated
@if (nomineeShareError) {
— {{ nomineeShareError }}
}
</span>
} @else if (splitBySequence && nominees.length > 0) {
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeOrderError">
@if (nomineeOrderError) {
{{ nomineeOrderError }}
} @else {
Paid in the order numbered, 1 first
}
</span>
}
}
</div>
@if (!nomineesRequired) {
<p class="pm-note">Set Nominee to Yes to nominate someone for this account.</p>
} @else if (nominees.length === 0) {
<div class="pm-nom-empty">
<i class="fas fa-user-plus"></i>
<p>No nominee added yet.</p>
<span>Use Add Nominee to name who the deposit is paid to.</span>
</div>
}
@for (nominee of nominees.controls; track nominee; let i = $index) {
<div class="pm-nom-card" [formGroup]="$any(nominee)">
<div class="pm-nom-card__head">
<span class="pm-nom-card__no">{{ i + 1 }}</span>
<span class="pm-nom-card__title">Nominee {{ i + 1 }}</span>
@if (isNomineeMinor(nominee)) {
<span class="pm-nom-card__badge">Minor</span>
}
<button
type="button"
class="pm-nom-card__remove"
(click)="removeNominee(i)"
matTooltip="Remove nominee"
aria-label="Remove nominee"
>
<i class="fas fa-trash"></i>
</button>
<div class="pm-nominee-row" [formGroup]="$any(nominee)">
<div class="pm-f pm-f--date">
<label class="pm-f-lbl">Nom.Dt<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
<mat-datepicker #nomDatePicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-nom-card__grid">
<div class="pm-f">
<label class="pm-f-lbl">Nomination Date<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required [matDatepicker]="nomDatePicker" formControlName="nominationDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomDatePicker"></mat-datepicker-toggle>
<mat-datepicker #nomDatePicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="name" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Mobile No</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="mobileNo" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Birth Date</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
<mat-datepicker #nomBirthPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Relation<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="relation">
@for (relation of relationChoices(nominee); track relation) {
<mat-option [value]="relation">{{ relation }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Gender<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<!-- Read from the date of birth, not asked: the two arriving separately is how
a ten-year-old came to be recorded as not a minor. -->
<label class="pm-f-lbl">Minor</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput readonly [value]="isNomineeMinor(nominee) ? 'Yes' : 'No'" />
<mat-hint>From date of birth</mat-hint>
</mat-form-field>
</div>
@if (splitByPercentage) {
<div class="pm-f">
<label class="pm-f-lbl">Share %<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input
matInput
type="number"
min="0.01"
max="100"
step="0.01"
required
formControlName="sharePercent"
/>
</mat-form-field>
</div>
} @else {
<div class="pm-f">
<label class="pm-f-lbl">Order<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" min="1" step="1" required formControlName="sequenceNo" />
</mat-form-field>
</div>
}
<!-- Only for a minor, who cannot receive the money themselves - an adult has no guardian to
name. Whatever was typed is cleared when the date of birth makes them an adult. -->
@if (isNomineeMinor(nominee)) {
<div class="pm-f pm-nom-card__guardian">
<label class="pm-f-lbl">Guardian Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="guardianName" />
<mat-hint>Required for a minor</mat-hint>
</mat-form-field>
</div>
}
<div class="pm-f">
<label class="pm-f-lbl">Name<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="name" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Mobile No</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput formControlName="mobileNo" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
<div class="pm-f pm-f--date">
<label class="pm-f-lbl">Birth Dt</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [matDatepicker]="nomBirthPicker" formControlName="birthDate" />
<mat-datepicker-toggle matIconSuffix [for]="nomBirthPicker"></mat-datepicker-toggle>
<mat-datepicker #nomBirthPicker></mat-datepicker>
</mat-form-field>
</div>
<div class="pm-f pm-f--tight">
<!-- Read from the date of birth, not asked: the two arriving separately is how
a ten-year-old came to be recorded as not a minor. -->
<label class="pm-f-lbl">Minor</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput readonly [value]="isNomineeMinor(nominee) ? 'Yes' : 'No'" />
<mat-hint>From date of birth</mat-hint>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Relation<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput required formControlName="relation" />
</mat-form-field>
</div>
<div class="pm-f pm-f--tight">
<label class="pm-f-lbl">Gender<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<mat-select required formControlName="gender">
<mat-option value="Male">Male</mat-option>
<mat-option value="Female">Female</mat-option>
<mat-option value="Other">Other</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">
Guar. Name
@if (isNomineeMinor(nominee)) {
<b>*</b>
}
</label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput [required]="isNomineeMinor(nominee)" formControlName="guardianName" />
@if (isNomineeMinor(nominee)) {
<mat-hint>Required for a minor</mat-hint>
}
</mat-form-field>
</div>
@if (splitByPercentage) {
<div class="pm-f pm-f--tight">
<label class="pm-f-lbl">Share %<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input
matInput
type="number"
min="0.01"
max="100"
step="0.01"
required
formControlName="sharePercent"
/>
</mat-form-field>
</div>
} @else {
<div class="pm-f pm-f--tight">
<label class="pm-f-lbl">Order<b>*</b></label>
<mat-form-field appearance="outline" class="pm-field">
<input matInput type="number" min="1" step="1" required formControlName="sequenceNo" />
</mat-form-field>
</div>
}
<button
mat-icon-button
type="button"
class="pm-remove-nominee"
(click)="removeNominee(i)"
matTooltip="Remove nominee"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
</div>
@@ -1163,16 +1036,6 @@
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
</button>
}
@if (selectedTabIndex > 0) {
<button mat-stroked-button type="button" (click)="selectedTabIndex = selectedTabIndex - 1">
<i class="fas fa-arrow-left" style="margin-right: 8px"></i>Previous
</button>
}
@if (selectedTabIndex < tabCount - 1) {
<button mat-stroked-button type="button" color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
Next<i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button>
}
<button
mat-raised-button
color="primary"
@@ -135,12 +135,75 @@ $primary: #0f766e;
height: 56px;
}
.pm-add-nominee {
color: $primary;
height: fit-content;
align-self: center;
}
/*
* The running total of the shares entered, beside the button that adds another. Reads as ordinary
* text while the split still adds up and turns to the warning colour when it does not, so the clerk
* sees the state of the whole nomination without having to try to save it.
*/
.pm-share-total {
align-self: center;
margin-left: 12px;
font-size: 13px;
color: rgb(0 0 0 / 60%);
}
.pm-share-total-bad {
color: #b3261e;
font-weight: 500;
}
.pm-empty {
color: rgb(0 0 0 / 50%);
font-size: 14px;
margin: 0 0 8px;
}
/*
* One nominee, laid out as fields that wrap rather than columns that must all fit.
*
* A fixed column per field only works while the count is fixed, and it is not - the Share % field
* appears and disappears with the split mode, and a tenth column pushed the remove button onto a
* line of its own. Each field instead states the width it needs to stay readable and the row breaks
* where it runs out of space, so nothing is squeezed and nothing is orphaned.
*/
.pm-nominee-row {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
align-items: start;
padding: 12px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
/* Names and addresses need room; a date, Yes/No or a percentage does not. */
.pm-f {
flex: 1 1 170px;
min-width: 0;
}
.pm-f--date {
flex: 0 1 140px;
}
.pm-f--tight {
flex: 0 1 100px;
}
}
.pm-remove-nominee {
color: #b91c1c;
margin-top: 8px;
}
.pm-action-bar {
display: flex;
justify-content: flex-end;
@@ -391,6 +454,24 @@ input.pm-money {
}
}
// The nominee grid is its own row of fields, so its labels sit above rather than beside - there is
// no room for a label column eight fields across.
.pm-nominee-row {
gap: 0 10px;
padding: 3px 0;
.pm-f {
grid-template-columns: minmax(0, 1fr);
row-gap: 1px;
}
.pm-f-lbl {
text-align: left;
padding-top: 0;
font-size: 10.5px;
}
}
.pm-note {
margin: 0;
font-size: 0.74rem;
@@ -445,478 +526,3 @@ input.pm-money {
border-color: rgb(180 83 9 / 25%);
color: #b45309;
}
/*
* Modern look.
*
* The Agent Master Entry screen's look, carried over to this one: an icon and a line of explanation in the header, a
* soft card with a tab bar, each group of fields in a panel of its own, labels above the fields they name. The fields,
* their order and every rule about them are as they were; only how they are drawn has changed. Kept compact - the
* fields stay short and the hint line under each stays, so an operator opening accounts all day still sees a good part
* of the form at once.
*/
$primary-dark: #0f4f3c;
$primary-light: #e6f4ee;
$border: #e2e8f0;
$text-dark: #0f172a;
$text-mid: #475569;
$danger: #b3261e;
.pm-container {
padding: 20px 16px 24px;
}
.pm-header {
gap: 14px;
margin-bottom: 16px;
&__icon {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
font-size: 19px;
box-shadow: 0 6px 16px rgb(15 79 60 / 22%);
}
&__text {
min-width: 0;
}
&__title {
font-size: 22px;
font-weight: 600;
color: $text-dark;
}
&__sub {
margin: 2px 0 0;
font-size: 13px;
color: $text-mid;
}
}
.pm-card {
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 2px 10px rgb(15 23 42 / 6%);
overflow: hidden;
// Field outlines: a quiet grey that turns to the brand colour when the field is used, with softer corners.
--mat-form-field-outlined-container-shape: 8px;
--mat-form-field-outlined-outline-color: #cbd5e1;
--mat-form-field-outlined-hover-outline-color: #94a3b8;
--mat-form-field-outlined-focus-outline-color: #{$primary};
}
.pm-tabs {
::ng-deep {
.mat-mdc-tab-header {
background: #f8fafc;
border-bottom: 1px solid $border;
}
// Seven tabs now share the row, so each gives up a little of its padding.
.mat-mdc-tab {
min-width: 100px;
padding: 0 14px;
height: 52px;
}
.mat-mdc-tab .mdc-tab__text-label {
color: $text-mid;
font-weight: 500;
}
.mat-mdc-tab.mdc-tab--active .mdc-tab__text-label {
color: $primary-dark;
font-weight: 600;
}
.mat-mdc-tab .mdc-tab-indicator__content--underline {
border-color: $primary;
border-top-width: 3px;
}
// A date picker's panel and a field's hint open past the edge of a tab; do not clip them.
.mat-mdc-tab-body,
.mat-mdc-tab-body-content {
overflow: visible !important;
}
}
}
.pm-tab-icon {
margin-right: 8px;
font-size: 14px;
}
// ── Nominee's Detail ────────────────────────────────────────────────────────────────────────────────
// What decides the nomination - whether there are nominees, how the deposit is split, how it adds up -
// sits in one bar above the nominees; each nominee is a card of its own.
.pm-nom-bar {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 8px 20px;
margin-bottom: 18px;
padding: 14px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #f8fafc;
&__applicable {
flex: 0 0 150px;
}
&__mode {
flex: 0 0 auto;
}
}
// Percentage or Sequence, as two pills rather than two loose radio buttons.
.pm-nom-mode {
display: inline-flex;
gap: 8px;
height: 56px;
align-items: center;
::ng-deep .mat-mdc-radio-button {
padding: 0 12px 0 2px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: #fff;
font-size: 13px;
}
::ng-deep .mat-mdc-radio-button.mat-mdc-radio-checked {
border-color: #1f7a4d;
background: #e6f4ee;
font-weight: 600;
}
}
.pm-nom-total {
flex: 1 1 240px;
align-self: center;
margin-bottom: 14px;
min-width: 200px;
&__text {
font-size: 13px;
color: $text-mid;
}
&__error {
margin-left: 4px;
}
&__track {
height: 6px;
margin-top: 6px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
&__fill {
height: 100%;
border-radius: 999px;
background: #f59e0b;
transition: width 0.2s ease;
&--done {
background: #16a34a;
}
}
// The split does not add up: the same warning colour the form has always used for it.
&--bad &__text {
color: $danger;
font-weight: 500;
}
&--bad &__fill {
background: $danger;
}
}
.pm-nom-add {
margin-bottom: 14px;
color: $primary;
white-space: nowrap;
}
.pm-nom-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 28px 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
color: #94a3b8;
text-align: center;
i {
font-size: 26px;
}
p {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $text-mid;
}
span {
font-size: 12.5px;
}
}
.pm-nom-card {
margin-bottom: 14px;
padding: 12px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
&__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
&__no {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: #e6f4ee;
color: #0f4f3c;
font-size: 12px;
font-weight: 700;
}
&__title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
&__badge {
padding: 2px 10px;
border-radius: 999px;
background: #fef3c7;
color: #b45309;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
&__remove {
margin-left: auto;
padding: 6px 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: $danger;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #fef2f2;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0 16px;
}
// The guardian is named on a line of its own, where it reads as belonging to the minor above it.
&__guardian {
grid-column: 1 / -1;
}
}
@media (width <= 900px) {
.pm-nom-card__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 560px) {
.pm-nom-card__grid {
grid-template-columns: 1fr;
}
}
// The party code, shown rather than asked for: it is given when the party is saved.
.pm-code {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
min-height: 56px;
padding: 6px 16px;
border: 1px solid #b7ddd0;
border-radius: 4px;
background: #e6f4ee;
&__value {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
color: #0f4f3c;
}
&__hint {
font-size: 11.5px;
color: #475569;
}
}
// A tab that holds a field still to be put right.
.pm-tab-alert {
margin-left: 8px;
font-size: 14px;
color: #b3261e;
}
.pm-tab-content {
padding: 16px 20px 6px;
}
// Each group of fields in a panel of its own, its name beside a small tinted icon.
.pm-section {
margin-bottom: 14px;
padding: 12px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
&:last-of-type {
margin-bottom: 8px;
border-bottom: 1px solid $border;
}
&__header {
margin-bottom: 10px;
gap: 2px 10px;
i {
display: inline-grid;
place-items: center;
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 8px;
background: rgb(15 118 110 / 10%);
color: $primary;
font-size: 12px;
}
h3 {
font-size: 12px;
color: $primary-dark;
}
}
}
.pm-section__sub {
font-size: 0.76rem;
color: $text-mid;
}
.pm-grid {
gap: 4px 16px;
}
// The label above the field it names, rather than beside it.
.pm-f {
grid-template-columns: minmax(0, 1fr);
row-gap: 2px;
}
// Material leaves 16px under every field for its own hint; the hint line is already reserved below, so the label above
// the next field is what separates the rows.
.pm-f .pm-field {
margin-bottom: 4px;
}
.pm-f-lbl {
padding-top: 0;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.01em;
text-align: left;
color: $text-mid;
b {
color: $danger;
}
}
.pm-card {
::ng-deep {
.mat-mdc-form-field-infix {
min-height: 30px;
}
// A field that cannot be typed in reads as filled-in fact, not as an empty box.
.mat-mdc-form-field.mat-form-field-disabled .mat-mdc-text-field-wrapper {
background: #f1f5f9;
}
.mat-mdc-form-field-hint {
color: $text-mid;
}
}
}
// The search icon is a button: it opens Search Customer.
.pm-search-btn {
margin-right: 4px;
padding: 4px 7px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgb(0 0 0 / 55%);
font-size: 0.85rem;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
&:hover:not(:disabled) {
background: $primary-light;
color: $primary-dark;
}
&:disabled {
cursor: default;
opacity: 0.4;
}
}
.pm-action-bar {
padding: 12px 20px;
background: #f8fafc;
}
.pm-callout {
margin: 0 20px 12px;
border-radius: 10px;
}
@@ -23,10 +23,6 @@ import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioModule } from '@angular/material/radio';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatDialog } from '@angular/material/dialog';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -37,6 +33,7 @@ import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
import { searchClientsAnyCase } from '../client-search';
/** Custom Components */
import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component';
@@ -124,133 +121,6 @@ export class CreatePartyComponent implements OnInit {
}
selectedTabIndex = 0;
/**
* How a nominee is related to the account holder, picked rather than typed. Read from the bank's own
* RELATIONSHIP code - m_code and m_code_value, the list a client's family members are recorded
* against - and from nowhere else, so a relation added or retired there shows up here.
*/
relationOptions: string[] = [];
/**
* The relations to offer on one nominee: the list, plus whatever that nominee already holds when it
* is not on it - a relation saved before this was a list must still show when edited.
*/
relationChoices(nominee: AbstractControl): string[] {
const current = String(nominee.get('relation')?.value ?? '').trim();
return current && !this.relationOptions.includes(current) ? [
...this.relationOptions,
current
] : this.relationOptions;
}
/** The code a new party is expected to get - an estimate; the server gives the real one on saving. */
expectedPartyCode = '';
/**
* The number of the DDS account opened most recently, shown as Last A/c. For reference only - it is
* read from the books, never typed, and nothing is saved from it.
*/
lastAccountNo = '';
/**
* Customer & Scheme, Personal & Contact, Minor / Guardian, Scheme Terms, Balances & Status,
* Nominee's Detail - and Maturity Payout between the last two when the account is paid into a
* savings account. Every field is part of the one form whichever tab it is on, so moving between
* tabs loses and changes nothing.
*/
get tabCount(): number {
return this.creditToRequired ? 7 : 6;
}
/** The controls each tab holds, so a tab can say it still has something to put right. */
private static readonly TAB_CONTROLS: Record<string, string[]> = {
customer: [
'clientId',
'subType',
'agentId',
'officeId',
'partyCode',
'nominal'
],
personal: [
'partyName',
'partyName1',
'address1',
'address2',
'address3',
'city',
'pincode',
'gender',
'telephone',
'mobileNoSms',
'aadharNo',
'panCardNo',
'ckycId',
'operation',
'birthDate',
'occupation',
'risk'
],
minor: [
'adultMinor',
'guardianClientId',
'minAccountOpt',
'minorDate',
'relation'
],
scheme: [
'openingDate',
'dailyDepositAmount',
'days',
'month',
'depositAmount',
'maturityDate',
'ledgerNo',
'reopenDate'
],
balances: [
'freezeCode',
'interestDate',
'isEmailActive',
'isSmsActive',
'lien',
'lienReason',
'lienReasonOther',
'od1Flag',
'od2Flag',
'payDate',
'spDescription'
],
payout: [
'creditToAccount',
'creditToAccountType',
'creditToBranch'
],
nominee: [
'nomineeApplicable',
'nomineeSplitMode'
]
};
/**
* Whether a tab holds something to put right: a field that is invalid and has been visited, or - on
* the nominee tab - a nominee whose shares or order do not add up. Display only; Save is decided as
* before.
*/
tabHasError(tab: string): boolean {
const visited = (control: AbstractControl | null) =>
!!control && control.invalid && (control.touched || control.dirty);
if (tab === 'nominee') {
const rows = this.nominees.controls.some((row) =>
Object.values((row as UntypedFormGroup).controls).some((control) => visited(control))
);
const flagged = CreatePartyComponent.TAB_CONTROLS['nominee'].some((name) => visited(this.partyForm.get(name)));
return rows || flagged || !!this.nomineeShareError || !!this.nomineeOrderError;
}
return (CreatePartyComponent.TAB_CONTROLS[tab] ?? []).some((name) => visited(this.partyForm.get(name)));
}
partyStatus = 'ACTIVE';
linkedSavingsAccountId: number | null = null;
@@ -282,9 +152,29 @@ export class CreatePartyComponent implements OnInit {
agentLookupFailed = false;
loadedGuardianName = '';
/** True when something other than an id has been typed in Client ID or Name: it is found with the search button. */
clientNeedsSearch = false;
/** Clients matching what has been typed - an officer knows the customer's name, rarely their id. */
clientOptions: any[] = [];
/**
* The first page of clients, shown the moment the field is focused so the officer can pick from a
* list instead of having to know something to type. Fetched once and kept.
*
* Capped rather than "all": it is a page of names on a branch with 28 customers today and a bank
* with far more later, and the field has to stay usable in both. Typing still searches every
* client, not just these - see searchClients.
*/
private initialClients: any[] = [];
private static readonly CLIENT_LIST_SIZE = 50;
/** How many clients exist in total, to say so when the list below is only part of them. */
clientTotal = 0;
/** True while the options being shown are the opening list rather than search results. */
showingClientList = false;
get clientListIsPartial(): boolean {
return this.showingClientList && this.clientTotal > this.clientOptions.length;
}
guardianOptions: any[] = [];
/** The account types matching what has been typed into Sub Type. */
subTypeOptions: any[] = [];
@@ -436,6 +326,12 @@ export class CreatePartyComponent implements OnInit {
Validators.required
],
savingsAccountNo: [''],
lastAccount: [
{
value: '',
disabled: true
}
],
partyCode: [
{
value: '',
@@ -653,30 +549,6 @@ export class CreatePartyComponent implements OnInit {
}
});
this.clientsService.getCodeValuesByName('RELATIONSHIP').subscribe((values: any[]) => {
const names = (values || [])
.filter((value: any) => value?.active !== false)
.map((value: any) => titleCase(String(value?.name ?? '').replace(/_/g, ' ')))
.filter((name: string) => !!name);
if (names.length > 0) {
this.relationOptions = names;
}
});
// The code a new party is expected to get, shown in place of a Party Code field - the server gives
// the code when the party is saved, never keyed. Read for an edit too, since Cancel leaves it
// ready for a new entry. A failure just leaves the card without a figure.
this.organizationService.getNextPartyCode().subscribe({
next: (result: any) => {
this.expectedPartyCode = result?.partyCode ?? '';
this.lastAccountNo = result?.lastAccountNo ?? '';
},
error: () => {
this.expectedPartyCode = '';
this.lastAccountNo = '';
}
});
const routeId = this.route.snapshot.paramMap.get('partyId');
if (routeId) {
this.organizationService.getParty(routeId).subscribe((party: any) => this.patchFormFromParty(party));
@@ -695,9 +567,9 @@ export class CreatePartyComponent implements OnInit {
// ---------------------------------------------------------------- lookups
/**
* Fills the identity block from the customer, the way the legacy screen did - keyed in as an id, which is fetched as
* it is typed (the way the Agent screen does it), or found by name or mobile number with the search button, which
* puts the id in the box.
* Fills the identity block from the customer, the way the legacy screen did - except that the
* customer can be found by name as well as by id, since an officer at the counter is told a name.
* The control holds the picked client object, or whatever has been typed so far.
*/
private wireClientLookup(): void {
this.partyForm
@@ -712,24 +584,72 @@ export class CreatePartyComponent implements OnInit {
this.partyForm.get('adultMinor')?.enable({ emitEvent: false });
this.loadedClientName = '';
this.clientLookupFailed = false;
this.clientNeedsSearch = false;
const typed = String(value && typeof value === 'object' ? value.id : (value ?? '')).trim();
// Picked from the list.
if (value && typeof value === 'object') {
this.clientOptions = [];
this.fillFromClient(value.id, value.displayName);
return;
}
const typed = String(value ?? '').trim();
if (!typed) {
// Emptying the field puts the opening list back, rather than leaving nothing to pick from.
this.showClientList();
return;
}
// A bare number is an id typed straight in.
// A bare number is an id typed straight in; anything else is a name to search for. A number
// can be both, so both are tried and whichever matches wins.
if (/^\d+$/.test(typed)) {
this.fillFromClient(typed, value && typeof value === 'object' ? value.displayName : '');
return;
this.fillFromClient(typed, '');
}
// A name is not looked up as it is typed; the search button finds the customer and puts the id here.
this.clientNeedsSearch = true;
this.searchClients(typed);
});
}
private searchClients(term: string): void {
searchClientsAnyCase(this.clientsService, term).subscribe((found: any[]) => {
this.showingClientList = false;
this.clientOptions = found.slice(0, 25);
});
}
/**
* Opens the customer list when the field is focused.
*
* Without this the officer has to know something before anything appears, which is backwards: the
* point of the field is to find a customer, and a blank box is no help in doing that. A customer
* already picked is left alone - reopening the list over a chosen name only invites a misclick.
*/
onClientFocus(): void {
if (this.partyForm.get('clientId')?.value) {
return;
}
this.showClientList();
}
/** Shows the opening list, fetching it the first time it is asked for. */
private showClientList(): void {
if (this.initialClients.length > 0) {
this.showingClientList = true;
this.clientOptions = this.initialClients;
return;
}
this.clientsService.getClients('displayName', 'ASC', 0, CreatePartyComponent.CLIENT_LIST_SIZE).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
this.showingClientList = true;
this.clientOptions = this.initialClients;
},
// A failed list is not a failed screen - the officer can still type a name or an id.
error: () => (this.clientOptions = [])
});
}
private fillFromClient(clientId: string | number, knownName: string): void {
this.clientsService.getClientData(String(clientId), true).subscribe({
next: (client: any) => {
@@ -857,35 +777,6 @@ export class CreatePartyComponent implements OnInit {
});
}
/**
* Opens Search Customer - the same dialog, and the same customer search API, the Agent screen and the cash
* screens use - to find the client by ID, name or mobile number. The id of the one picked goes into the field,
* which then fills in the rest as an id keyed in does.
*/
openClientSearch(): void {
const control = this.partyForm.get('clientId');
if (!control || control.disabled) {
return;
}
// What was typed so far is searched for; a client already picked is not a search term.
const value = control.value;
const typed = value && typeof value === 'object' ? '' : String(value ?? '').trim();
this.dialog
.open(CustomerSearchDialogComponent, {
width: '960px',
maxWidth: '95vw',
// A party is a customer first: they need not hold an account to be picked, and are listed once however many
// accounts they have.
data: { requireAccount: false, uniqueCustomers: true, prefill: typed }
})
.afterClosed()
.subscribe((result: CustomerSearchResult | undefined) => {
if (result?.clientId) {
control.setValue(String(result.clientId));
}
});
}
/** What the Client ID box shows for a picked client, rather than "[object Object]". */
displayClient = (value: any): string => {
if (!value) {
@@ -912,13 +803,7 @@ export class CreatePartyComponent implements OnInit {
return this.filledFromClient.has(controlName);
}
/**
* The client's risk category, PAN and CKYC number, from their KYC record.
*
* The PAN and the CKYC number are filed on the KYC record as well as - for some clients, only on -
* it, not as ID proofs. Reading the ID proofs alone left both empty for a client whose KYC held them,
* which is the record the client screens and the customer search read first.
*/
/** The client's risk category, from their KYC record. */
private loadClientRisk(clientId: string): void {
this.clientsService.getClientKyc(clientId, true).subscribe({
next: (kyc: any) => {
@@ -927,8 +812,6 @@ export class CreatePartyComponent implements OnInit {
.split('.')
.pop();
this.fillFromClientRecord('risk', titleCase(code));
this.fillFromClientRecord('panCardNo', kyc?.panNumber);
this.fillFromClientRecord('ckycId', kyc?.ckycNumber);
},
// A client without a KYC record just leaves the risk to be chosen.
error: () => undefined
@@ -1671,6 +1554,7 @@ export class CreatePartyComponent implements OnInit {
officeId: raw.officeId,
savingsAccountNo: this.linkedSavingsAccountId ? undefined : raw.savingsAccountNo || undefined,
subType: raw.subType,
lastAccount: raw.lastAccount || undefined,
nominal: raw.nominal === 'Yes',
aadharNo: raw.aadharNo || undefined,
partyName: raw.partyName,
@@ -1966,6 +1850,7 @@ export class CreatePartyComponent implements OnInit {
agentId: party.agentId,
officeId: party.officeId,
partyCode: party.partyCode,
lastAccount: party.lastAccount,
nominal: party.nominal ? 'Yes' : 'No',
aadharNo: party.aadharNo,
partyName: party.partyName,
@@ -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;
}
@@ -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 = [])
});
}
@@ -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;
@@ -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 }} &middot;
{{ 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) &middot;
{{ installmentPosition.overdueAmount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
}
@if (
recurringDepositsAccountData.timeline.closedOnDate || recurringDepositsAccountData.summary.totalWitddrawals
) {
@@ -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
@@ -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 &amp; 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 &amp; products)
</button>
<button
type="button"
class="pg-btn pg-btn--ghost"
[class.pg-btn--active]="view === 'BORROWERS'"
(click)="view = 'BORROWERS'"
>
Borrowers &amp; 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;
}
@@ -0,0 +1,15 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { NgModule } from '@angular/core';
import { LoanEligibilityRoutingModule } from './loan-eligibility-routing.module';
@NgModule({
imports: [LoanEligibilityRoutingModule]
})
export class LoanEligibilityModule {}
@@ -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'));
}
}
+1
View File
@@ -967,6 +967,7 @@ export const REPORT_META: ReportMeta[] = [
{ id: 'probable', group: 'classification', label: 'Probable NPA' },
{ id: 'sma', group: 'classification', label: 'SMA Report' },
{ id: 'recovery-dashboard', group: 'recovery-mgmt', label: 'Recovery Management Dashboard' },
{ id: 'cc-recovery', group: 'recovery-mgmt', label: 'Cash Credit (CC) Recovery Management' },
{ id: 'daywise-overdue', group: 'recovery-mgmt', label: 'Day-wise Overdue Report' },
{ id: 'daywise-overdrawn', group: 'recovery-mgmt', label: 'Day-wise Overdrawn Report' },
{ id: 'agri-gold-value', group: 'recovery-mgmt', label: 'Agriculture / Gold Market Value Report' },
@@ -1400,6 +1400,184 @@
</div>
}
<!-- ===================== CASH CREDIT (CC) RECOVERY MANAGEMENT ===================== -->
@if (activeReport === 'cc-recovery') {
<div class="pg-card">
<div class="pos-section-label">
Cash Credit (CC) Recovery Management — Drawing Power &amp; Overdrawn-Day Tracking
</div>
<p class="pg-sub npa-report-note">
Cash Credit is a revolving working-capital facility secured against stock / receivables. Unlike a Term Loan, it
isn't checked against a fixed instalment — it is checked against its <strong>Drawing Power (DP)</strong>, and
tracked by how many continuous <strong>Days Overdrawn</strong> ("Out of Order") it has run — crossing 90 days
turns it <strong>NPA</strong>.
</p>
<div class="npa-glossary">
<div class="npa-glossary-card npa-glossary-card--dp">
<span class="npa-glossary-card__icon"><fa-icon icon="wallet"></fa-icon></span>
<div class="npa-glossary-card__term">Drawing Power (DP)</div>
<p class="npa-glossary-card__desc">
The maximum amount the borrower may actually draw against the sanctioned CC limit — fixed from the value of
stock / book-debts declared in the latest stock statement. Can be lower than the sanctioned limit.
</p>
</div>
<div class="npa-glossary-card npa-glossary-card--ooo">
<span class="npa-glossary-card__icon"><fa-icon icon="hourglass-half"></fa-icon></span>
<div class="npa-glossary-card__term">Out of Order</div>
<p class="npa-glossary-card__desc">
A CC account turns "Out of Order" once its outstanding continuously stays above the limit/DP, or adequate
credits stop coming in, for 90 days — the CC-specific overdue trigger, in place of a missed EMI.
</p>
</div>
<div class="npa-glossary-card npa-glossary-card--sma">
<span class="npa-glossary-card__icon"><fa-icon icon="flag"></fa-icon></span>
<div class="npa-glossary-card__term">Days Overdrawn</div>
<p class="npa-glossary-card__desc">
The running count of continuous days an account has stayed Out of Order — the number a recovery desk
actually watches day to day, instead of a classification label. A rising count is the early-warning sign.
</p>
</div>
<div class="npa-glossary-card npa-glossary-card--npa">
<span class="npa-glossary-card__icon"><fa-icon icon="ban"></fa-icon></span>
<div class="npa-glossary-card__term">NPA (Non-Performing Asset)</div>
<p class="npa-glossary-card__desc">
Once Out of Order crosses 90 days, the CC account slips to NPA — Sub-Standard, then Doubtful-1/2/3, then
Loss Asset as it ages further.
</p>
</div>
</div>
</div>
<div class="pg-card">
<div class="inv-kpis npa-kpis--ratio npa-kpis--compact">
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--neutral">
<span class="npa-kpi__icon"><fa-icon icon="wallet"></fa-icon></span>
<span class="inv-kpi__label">Total CC Accounts</span>
<span class="inv-kpi__value">{{ ccAccounts.length }}</span>
<span class="inv-kpi__sub">₹{{ ccTotalSanctioned | number: '1.0-0' }} sanctioned</span>
</div>
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--warn">
<span class="npa-kpi__icon"><fa-icon icon="hourglass-half"></fa-icon></span>
<span class="inv-kpi__label">Overdrawn (1–90 Days)</span>
<span class="inv-kpi__value" [class.npa-warn]="ccOverdrawnCount > 0">{{ ccOverdrawnCount }}</span>
<span class="inv-kpi__sub">Out of Order, not yet NPA</span>
</div>
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--npa">
<span class="npa-kpi__icon"><fa-icon icon="ban"></fa-icon></span>
<span class="inv-kpi__label">NPA (90+ Days Overdrawn)</span>
<span class="inv-kpi__value npa-warn">{{ ccNpaCount }}</span>
<span class="inv-kpi__sub">₹{{ ccTotalOutstanding | number: '1.0-0' }} total outstanding</span>
</div>
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--info">
<span class="npa-kpi__icon"><fa-icon icon="money-bill-wave"></fa-icon></span>
<span class="inv-kpi__label">Interest Paid (This Cycle)</span>
<span class="inv-kpi__value">₹{{ ccInterestPaidThisCycle | number: '1.0-0' }}</span>
<span class="inv-kpi__sub">of ₹{{ ccInterestDueThisCycle | number: '1.0-0' }} due</span>
</div>
<div class="inv-kpi npa-kpi--accent npa-kpi--accent--positive">
<span class="npa-kpi__icon"><fa-icon icon="hand-holding-usd"></fa-icon></span>
<span class="inv-kpi__label">Total Recovered</span>
<span class="inv-kpi__value">₹{{ ccTotalRecovered | number: '1.0-0' }}</span>
<span class="inv-kpi__sub"
>{{ ccRecoveryPct | number: '1.1-1' }}% of ₹{{ ccTotalSanctioned | number: '1.0-0' }} sanctioned</span
>
</div>
</div>
</div>
<div class="pg-card">
<div class="pos-section-label">
Cash Credit Accounts — Limit, DP &amp; Classification ({{ ccOutOfOrderCount }} of {{ ccAccounts.length }} Out of
Order)
</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th class="num">Sanctioned Limit</th>
<th class="num">Drawing Power (DP)</th>
<th class="num">Outstanding</th>
<th class="num col-out">Pending to Recover</th>
<th class="num col-out">Excess Over DP</th>
<th class="num">Interest Due (This Cycle)</th>
<th class="num col-in">Interest Paid (This Cycle)</th>
<th class="num col-in">Total Recovered</th>
<th class="num">Days Overdrawn</th>
<th>Status</th>
<th>Recovery Officer</th>
</tr>
</thead>
<tbody>
@for (a of paged(ccAccounts); track a.accountNo) {
<tr>
<td>
<span class="pg-chip">{{ a.accountNo }}</span>
</td>
<td>{{ a.customerName }}</td>
<td>{{ a.branchName }}</td>
<td class="num">₹{{ a.sanctionedAmount | number: '1.0-0' }}</td>
<td class="num">₹{{ a.drawingPower | number: '1.0-0' }}</td>
<td class="num">₹{{ a.outstandingBalance | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ a.overdueAmount | number: '1.0-0' }}</td>
<td class="num col-out">₹{{ excessOverDp(a) | number: '1.0-0' }}</td>
<td class="num">₹{{ expectedThisMonth(a) | number: '1.0-0' }}</td>
<td class="num" [class.col-in]="actualThisMonth(a) > 0" [class.col-out]="actualThisMonth(a) === 0">
₹{{ actualThisMonth(a) | number: '1.0-0' }}
</td>
<td class="num col-in">₹{{ recovered(a) | number: '1.0-0' }}</td>
<td class="num">{{ isNpaStageOf(a) || overdrawnBreachOf(a) ? a.overdueDays : 0 }}</td>
<td>
<span
class="npa-badge"
[class.npa-badge--standard]="!isNpaStageOf(a) && !overdrawnBreachOf(a)"
[class.npa-badge--sma1]="!isNpaStageOf(a) && overdrawnBreachOf(a)"
[class.npa-badge--substandard]="isNpaStageOf(a)"
>
{{ isNpaStageOf(a) ? 'NPA' : overdrawnBreachOf(a) ? 'Overdrawn' : 'Regular' }}
</span>
</td>
<td>{{ a.recoveryOfficer }}</td>
</tr>
} @empty {
<tr>
<td colspan="14" class="pg-empty">No Cash Credit accounts in this branch.</td>
</tr>
}
</tbody>
</table>
</div>
@if (ccAccounts.length > pageSize) {
<div class="pg-pagination">
<span class="pg-pagination__info"
>Showing {{ rangeStart(ccAccounts) }}–{{ rangeEnd(ccAccounts) }} of {{ ccAccounts.length }}</span
>
<div class="pg-pagination__controls">
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === 1"
(click)="goToPage(currentPage - 1, ccAccounts)"
>
‹ Prev
</button>
<span class="pg-pagination__page">Page {{ currentPage }} of {{ pageCount(ccAccounts) }}</span>
<button
type="button"
class="pg-btn pg-btn--ghost"
[disabled]="currentPage === pageCount(ccAccounts)"
(click)="goToPage(currentPage + 1, ccAccounts)"
>
Next ›
</button>
</div>
</div>
}
</div>
}
<!-- ===================== 14 AGRICULTURE / GOLD MARKET VALUE REPORT ===================== -->
@if (activeReport === 'agri-gold-value') {
<div class="pg-card">
@@ -660,6 +660,69 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
return this.overdrawnAccounts.filter((a) => this.overdrawnBreachOf(a)).length;
}
// -------------------- Cash Credit (CC) Recovery Management --------------------
/** Pure Cash Credit (MSME) accounts only — product code '01' — kept separate from the combined
* Overdrawn (CC/OD/KCC) bucket so CC's own Drawing Power / Out-of-Order / SMA-NPA position can
* be reviewed on its own, the way a recovery desk actually works a CC book. */
get ccAccounts(): LoanAccount[] {
return this.allAccounts
.filter((a) => a.productCode === '01')
.sort((a, b) => this.excessOverDp(b) - this.excessOverDp(a));
}
/** Any CC account that's irregular — either drawn beyond its Drawing Power right now, or already
* NPA-staged from its overdrawn history (an NPA account doesn't "go Regular" just because it's
* briefly back within DP — RBI upgrade norms require full clearance first). Covers the same
* population the 1–90-day Overdrawn and 90+-day NPA buckets below split in two. */
get ccOutOfOrderCount(): number {
return this.ccAccounts.filter((a) => this.overdrawnBreachOf(a) || this.isNpaStageOf(a)).length;
}
/** Out of Order for 1–90 continuous days — the pre-NPA monitoring window a recovery desk tracks
* by plain day-count, not by an SMA-0/1/2 label (that split only matters for regulatory
* reporting, not the day-to-day collection screen). */
get ccOverdrawnCount(): number {
return this.ccAccounts.filter((a) => this.overdrawnBreachOf(a) && !this.isNpaStageOf(a)).length;
}
/** Out of Order for more than 90 continuous days — the point RBI norms classify a CC/OD account
* as NPA. */
get ccNpaCount(): number {
return this.ccAccounts.filter((a) => this.isNpaStageOf(a)).length;
}
get ccTotalSanctioned(): number {
return this.ccAccounts.reduce((s, a) => s + a.sanctionedAmount, 0);
}
get ccTotalOutstanding(): number {
return this.ccAccounts.reduce((s, a) => s + a.outstandingBalance, 0);
}
/** A Cash Credit account has no fixed EMI — each cycle's "due" amount is interest servicing on
* the drawn balance, so this total doubles as "how much interest was CC customers due to pay
* this cycle". */
get ccInterestDueThisCycle(): number {
return this.ccAccounts.reduce((s, a) => s + this.expectedThisMonth(a), 0);
}
/** How much of that interest the CC book has actually paid this cycle — the answer to "which
* customers have paid how much interest", summed across the Cash Credit book. */
get ccInterestPaidThisCycle(): number {
return this.ccAccounts.reduce((s, a) => s + this.actualThisMonth(a), 0);
}
/** Lifetime cash recovered against the CC book (recorded recovery transactions — cash deposits,
* cheques, NEFT/RTGS, auction/SARFAESI, OTS) — the answer to "loan diya kitna, recover kitna
* hua", measured against what was actually sanctioned. */
get ccTotalRecovered(): number {
return this.ccAccounts.reduce((s, a) => s + this.recovered(a), 0);
}
get ccRecoveryPct(): number {
return this.ccTotalSanctioned ? (this.ccTotalRecovered / this.ccTotalSanctioned) * 100 : 0;
}
// -------------------- Agriculture / Gold Market Value Report --------------------
get agriGoldAccounts(): LoanAccount[] {
return this.allAccounts
@@ -1617,6 +1680,63 @@ export class NpaReportsComponent implements OnInit, OnDestroy {
}
];
case 'cc-recovery':
return [
{
sheetName: 'Cash Credit Recovery',
title: 'Cash Credit (CC) Recovery Management — Drawing Power & Overdrawn-Day Tracking',
subtitle,
headers: [
'Account No',
'Customer',
'Branch',
'Sanctioned Limit',
'Drawing Power',
'Outstanding',
'Pending to Recover',
'Excess Over DP',
'Interest Due (This Cycle)',
'Interest Paid (This Cycle)',
'Total Recovered',
'Days Overdrawn',
'Status',
'Recovery Officer'
],
widths: [
16,
22,
16,
16,
16,
16,
16,
16,
18,
18,
18,
14,
14,
20
],
rows: this.ccAccounts.map((a) => [
a.accountNo,
a.customerName,
a.branchName,
a.sanctionedAmount,
a.drawingPower,
a.outstandingBalance,
a.overdueAmount,
this.excessOverDp(a),
this.expectedThisMonth(a),
this.actualThisMonth(a),
this.recovered(a),
this.isNpaStageOf(a) || this.overdrawnBreachOf(a) ? a.overdueDays : 0,
this.isNpaStageOf(a) ? 'NPA' : this.overdrawnBreachOf(a) ? 'Overdrawn' : 'Regular',
a.recoveryOfficer
])
}
];
case 'daywise-overdrawn':
return [
{
+159
View File
@@ -94,6 +94,165 @@
grid-template-columns: repeat(3, 1fr);
}
// -----------------------------------------------------------------------
// Cash Credit Recovery Management — a glossary strip explaining DP / Out of
// Order / SMA / NPA to a reader meeting the terms for the first time (e.g. a
// stakeholder demo), plus tone-coloured accents for that screen's KPI row.
// -----------------------------------------------------------------------
.npa-glossary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 14px;
margin-top: 18px;
}
.npa-glossary-card {
padding: 16px 16px 16px 18px;
border-radius: 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-left: 4px solid #64748b;
transition: box-shadow 0.15s ease;
&:hover {
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 14px;
margin-bottom: 10px;
}
&__term {
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #0f172a;
margin-bottom: 6px;
}
&__desc {
font-size: 12.5px;
line-height: 1.55;
color: #475569;
margin: 0;
}
&--dp {
border-left-color: #2563eb;
.npa-glossary-card__icon {
background: #dbeafe;
color: #2563eb;
}
}
&--ooo {
border-left-color: #d97706;
.npa-glossary-card__icon {
background: #fef3c7;
color: #b45309;
}
}
&--sma {
border-left-color: #ca8a04;
.npa-glossary-card__icon {
background: #fef9c3;
color: #854d0e;
}
}
&--npa {
border-left-color: #dc2626;
.npa-glossary-card__icon {
background: #fee2e2;
color: #dc2626;
}
}
}
// Compact gap variant for a KPI row that otherwise uses npa-kpis--ratio's 4-wide grid — used by
// the CC Recovery Management summary row, which packs more (smaller) tiles than the main header.
.npa-kpis--compact {
gap: 8px;
}
// Tone-coloured KPI tile for the CC summary row — same .inv-kpi tile, with a left accent + icon
// chip so "Out of Order"/"SMA"/"NPA" read as a severity strip rather than four identical boxes.
// Sized smaller than the default .inv-kpi tile since this row packs 6 of them.
.npa-kpi--accent {
padding: 10px 12px 10px 14px;
border-left: 4px solid var(--npa-kpi-accent, #64748b);
.inv-kpi__label {
font-size: 9px;
}
.inv-kpi__value {
font-size: 16px;
}
.inv-kpi__sub {
font-size: 10px;
}
.npa-kpi__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 999px;
background: var(--npa-kpi-accent-soft, #f1f5f9);
color: var(--npa-kpi-accent, #475569);
font-size: 10px;
margin-bottom: 4px;
}
&--neutral {
--npa-kpi-accent: #0f4f3c;
--npa-kpi-accent-soft: #dcfce7;
}
&--warn {
--npa-kpi-accent: #d97706;
--npa-kpi-accent-soft: #fef3c7;
}
&--sma {
--npa-kpi-accent: #ca8a04;
--npa-kpi-accent-soft: #fef9c3;
}
&--npa {
--npa-kpi-accent: #dc2626;
--npa-kpi-accent-soft: #fee2e2;
}
&--info {
--npa-kpi-accent: #2563eb;
--npa-kpi-accent-soft: #dbeafe;
}
&--positive {
--npa-kpi-accent: #15803d;
--npa-kpi-accent-soft: #dcfce7;
}
}
// Account Detail header — title/badge on the left, the Recovery Journey trigger pinned right.
.npa-detail-header {
display: flex;
@@ -159,22 +159,11 @@
<!-- A closed agent is off the books: nothing can be changed on it, only read. -->
@if (a.closeDate) {
<span class="am-closed-on">Closed {{ a.closeDate | dateFormat }}</span>
}
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[a.id]"
(click)="$event.stopPropagation()"
matTooltip="View agent details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
@if (!a.closeDate) {
} @else {
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[a.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this agent"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
@@ -187,7 +176,6 @@
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[routerLink]="[row.id]"
class="pl-row"
[class.am-row--inactive]="!row.active"
></tr>
@@ -11,470 +11,298 @@
<button mat-stroked-button [routerLink]="['../']" class="am-back">
<i class="fas fa-arrow-left"></i> {{ 'labels.heading.Organization' | translate }}
</button>
<div class="am-header__icon"><i class="fas fa-user-tie"></i></div>
<div class="am-header__text">
<h2 class="am-header__title">Agent Master Entry</h2>
<p class="am-header__sub">
{{ agentId ? 'Update this collection agent.' : 'Register a collection agent, their accounts and guarantors.' }}
</p>
</div>
<h2 class="am-header__title">Agent Master Entry</h2>
</div>
<form [formGroup]="agentForm">
<!--
Follows the legacy Agent Master Entry screen field for field. Entering a Client ID fills the
name, address, city, pincode and telephone from that client, as the legacy screen did, and
also defaults the home branch and stocks the two savings-account dropdowns from that client.
The fields are the same ones the legacy screen had, set out over tabs so each page is short;
they all belong to the one form, so nothing is lost by moving between tabs.
-->
<mat-tab-group
class="am-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<!-- ── 1. Agent details ─────────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-id-card am-tab-icon"></i>
Agent Details
@if (tabHasError(0)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro">
<h3>Agent Details</h3>
<p>
Choose the branch, then find the client who will act as agent - their name, address and accounts are
filled in from them.
</p>
</div>
<div class="am-grid">
<!-- Asked first: the clients offered, and whether a client can be made an agent here at all,
depend on the branch. Which branch the agent collects out of: the one logged in to or a
branch under it. The option carries the office's id while showing its name, so renaming
a branch later leaves the agent where it is. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Home Branch</mat-label>
<mat-select required formControlName="officeId">
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
<mat-hint>The branch this agent's collections belong to</mat-hint>
</mat-form-field>
<div class="am-card">
<form [formGroup]="agentForm">
<!--
Follows the legacy Agent Master Entry screen field for field. Entering a Client ID fills the
name, address, city, pincode and telephone from that client, as the legacy screen did, and
also defaults the home branch and stocks the two savings-account dropdowns from that client.
-->
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-id-card"></i>
<h3>Agent Details</h3>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID or Name</mat-label>
<input
matInput
required
formControlName="clientId"
[matAutocomplete]="clientAuto"
#clientInput
#clientTrigger="matAutocompleteTrigger"
(focus)="onClientFocus()"
placeholder="Click to see clients, or type a name or ID…"
autocomplete="off"
/>
<i matSuffix class="fas fa-search am-search-ic"></i>
<!-- Picking a client puts their id in the field, which then fills the rest as before. -->
<mat-autocomplete #clientAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (client of clientOptions; track client.id) {
<mat-option [value]="client.id">
<div class="am-opt">
<span class="am-opt-id">{{ client.id }}</span>
<span class="am-opt-main">{{ client.displayName }}</span>
<span class="am-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
@if (clientListIsPartial) {
<mat-option disabled class="am-opt-more">
Showing {{ clientOptions.length }} of {{ clientTotal }} — type a name to find the rest
</mat-option>
}
</mat-autocomplete>
@if (agentForm.get('clientId')?.hasError('pattern')) {
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
} @else if (clientAlreadyAgent) {
<mat-hint class="am-hint--error">This client is already agent {{ clientAlreadyAgent }}</mat-hint>
} @else if (clientAgeProblem) {
<mat-hint class="am-hint--error">{{ clientAgeProblem }}</mat-hint>
} @else if (clientOtherBranch) {
<mat-hint class="am-hint--error">This client belongs to another branch</mat-hint>
} @else if (clientLookupFailed) {
<mat-hint class="am-hint--error">No client found with this ID</mat-hint>
} @else if (loadedClientName) {
<mat-hint>{{ loadedClientName }}</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Code</mat-label>
<input matInput formControlName="agentCode" placeholder="Auto-generated" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Name ID</mat-label>
<input matInput required formControlName="agentNameId" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 1)</mat-label>
<input matInput required formControlName="agentAddress1" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 2)</mat-label>
<input matInput formControlName="agentAddress2" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent City</mat-label>
<input matInput required formControlName="agentCity" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent PinCode</mat-label>
<input matInput required formControlName="agentPinCode" mifosxRestrictInput="digits" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Telephone</mat-label>
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
</mat-form-field>
<!-- Which branch the agent collects out of: the one logged in to or a branch under it. The
option carries the office's id while showing its name, so renaming a branch later
leaves the agent where it is. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Home Branch</mat-label>
<mat-select required formControlName="officeId">
@for (office of offices; track office.id) {
<mat-option [value]="office.id">{{ office.name }}</mat-option>
}
</mat-select>
<mat-hint>The branch this agent's collections belong to</mat-hint>
</mat-form-field>
<!-- The account this agent's cash sits in between collecting it and bringing it in, so a
balance left standing on it is this agent's and nobody else's. Required, and one agent
to an account - an account already taken is refused by name, not by number. Only
detail accounts are offered: a header account is a heading over others and carries no
entries of its own. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>GL Account</mat-label>
<mat-select required formControlName="glAccountId">
@for (account of glAccounts; track account.id) {
<mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option>
}
</mat-select>
<mat-hint>This agent's collections pass through it. One agent to an account.</mat-hint>
</mat-form-field>
<!-- Type first, then the number - the order the legacy screen used. The list is built from
the client's own accounts, so every type offered is one they actually hold. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C Type</mat-label>
<mat-select
required
formControlName="agentSavingAccountType"
(selectionChange)="onSavingsAccountTypePicked()"
>
@for (accountType of savingsAccountTypes; track accountType) {
<mat-option [value]="accountType">{{ savingsAccountTypeCodes[accountType] || accountType }}</mat-option>
}
</mat-select>
<!-- Two different reasons for an empty list, and the fix differs: enter a client, or
open them a savings account. Saying which saves a guess. -->
@if (savingsAccountTypes.length === 0) {
@if (loadedClientName) {
<mat-hint class="am-hint--error">
{{ loadedClientName }} has no savings account — commission can only be paid into one
</mat-hint>
} @else {
<mat-hint class="am-hint--error"
>Enter a Client ID first — types come from that client's own accounts</mat-hint
>
}
}
</mat-form-field>
<!-- Picked from the client's own accounts, never typed: commission is paid into this
account, and a number that exists only as text is a payment with nowhere to land. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C No</mat-label>
<mat-select required formControlName="agentSavingAccountNo">
@for (account of accountsOfChosenType; track account.id) {
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
}
</mat-select>
@if (accountsOfChosenType.length === 0) {
<mat-hint>Choose an A/C Type first</mat-hint>
}
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Date</mat-label>
<input matInput required [matDatepicker]="openingDatePicker" formControlName="agentOpeningDate" />
<mat-datepicker-toggle matIconSuffix [for]="openingDatePicker"></mat-datepicker-toggle>
<mat-datepicker #openingDatePicker></mat-datepicker>
</mat-form-field>
<!-- Copied from the client, whose date of birth was taken and checked when they were opened. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Birth Date</mat-label>
<input matInput readonly [matDatepicker]="birthDatePicker" formControlName="agentBirthDate" />
<mat-datepicker #birthDatePicker></mat-datepicker>
<mat-hint>From the client's record</mat-hint>
</mat-form-field>
</div>
</div>
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-user-shield"></i>
<h3>Guarantor's Detail</h3>
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
<i class="fas fa-plus"></i> Add Guarantor
</button>
</div>
@if (guarantors.length === 0) {
<p class="am-empty-guarantors">No guarantors added yet.</p>
}
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
<div class="am-guarantor-row" [formGroup]="$any(guarantor)">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID or Name</mat-label>
<input
matInput
required
formControlName="clientId"
(keydown.enter)="$event.preventDefault(); openClientSearch()"
placeholder="Client ID, or search by name or mobile number…"
[matAutocomplete]="guarantorAuto"
#guarantorInput
#guarantorTrigger="matAutocompleteTrigger"
(focus)="onGuarantorFocus(guarantor, guarantorTrigger, guarantorInput)"
placeholder="Click to see clients, or type…"
autocomplete="off"
/>
<!-- Opens Search Customer; picking a customer puts their id here, which fills the rest. -->
<button
matSuffix
type="button"
class="am-search-btn"
aria-label="Search customer"
[disabled]="agentId !== null"
(click)="openClientSearch()"
>
<i class="fas fa-search"></i>
</button>
@if (clientNeedsSearch) {
<mat-hint class="am-hint--error">Press search to find this client by name or mobile number</mat-hint>
} @else if (clientAlreadyAgent) {
<mat-hint class="am-hint--error">This client is already agent {{ clientAlreadyAgent }}</mat-hint>
} @else if (clientAgeProblem) {
<mat-hint class="am-hint--error">{{ clientAgeProblem }}</mat-hint>
} @else if (clientOtherBranch) {
<mat-hint class="am-hint--error">This client belongs to another branch</mat-hint>
} @else if (clientLookupFailed) {
<mat-hint class="am-hint--error">No client found with this ID</mat-hint>
} @else if (loadedClientName) {
<mat-hint>{{ loadedClientName }}</mat-hint>
<i matSuffix class="fas fa-search am-search-ic"></i>
<mat-autocomplete #guarantorAuto="matAutocomplete" panelWidth="min(30rem, 92vw)">
@for (client of guarantorOptionsFor(guarantor); track client.id) {
<mat-option [value]="client.id">
<div class="am-opt">
<span class="am-opt-id">{{ client.id }}</span>
<span class="am-opt-main">{{ client.displayName }}</span>
<span class="am-opt-sub">{{ client.officeName }}</span>
</div>
</mat-option>
}
</mat-autocomplete>
<!-- Shown as soon as it is keyed, not only after leaving the field. -->
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
<mat-hint class="am-hint--error">The agent cannot be their own guarantor</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) {
<mat-hint class="am-hint--error">This client is already a guarantor above</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('pattern')) {
<mat-hint class="am-hint--error">Pick a client from the list</mat-hint>
}
</mat-form-field>
<!-- Not a field: the server assigns the code when the agent is saved. A new agent is shown
the code it is expected to get, worked out from the agents already on the books. -->
<div class="am-code">
<span class="am-code__label">Agent Code</span>
<span class="am-code__value">{{
(agentId ? agentForm.get('agentCode')?.value : expectedAgentCode) || '—'
}}</span>
<span class="am-code__hint">{{
agentId ? 'Assigned when this agent was opened' : 'Expected - assigned when saved'
}}</span>
</div>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Name ID</mat-label>
<input matInput required formControlName="agentNameId" />
</mat-form-field>
<!-- Not picked: a new agent is opened on the business date, and an agent being edited shows
the date it was opened on. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Date</mat-label>
<input
matInput
required
readonly
[matDatepicker]="openingDatePicker"
formControlName="agentOpeningDate"
/>
<mat-datepicker #openingDatePicker></mat-datepicker>
<mat-hint>{{ agentId ? 'The date this agent was opened' : 'Business date' }}</mat-hint>
<mat-label>Guarantor Name</mat-label>
<input matInput required formControlName="guarantorName" />
</mat-form-field>
<!-- Copied from the client, whose date of birth was taken and checked when they were opened. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Birth Date</mat-label>
<input matInput readonly [matDatepicker]="birthDatePicker" formControlName="agentBirthDate" />
<mat-datepicker #birthDatePicker></mat-datepicker>
<mat-hint>From the client's record</mat-hint>
<mat-label>Address 1</mat-label>
<input matInput required formControlName="address1" />
</mat-form-field>
</div>
</div>
</mat-tab>
<!-- ── 2. Address & contact ─────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-map-marker-alt am-tab-icon"></i>
Address &amp; Contact
@if (tabHasError(1)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro">
<h3>Address &amp; Contact</h3>
<p>Filled from the client; change anything that differs for the agent.</p>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 1)</mat-label>
<input matInput required formControlName="agentAddress1" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Address (Line 2)</mat-label>
<input matInput formControlName="agentAddress2" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent City</mat-label>
<input matInput required formControlName="agentCity" />
<mat-label>Address 2</mat-label>
<input matInput formControlName="address2" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent PinCode</mat-label>
<input matInput required formControlName="agentPinCode" mifosxRestrictInput="digits" />
<mat-label>City</mat-label>
<input matInput required formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Telephone</mat-label>
<input matInput formControlName="agentTelephone" mifosxRestrictInput="phone" />
<mat-label>Pin Code</mat-label>
<input matInput required formControlName="pinCode" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
</div>
</mat-tab>
<!-- ── 3. Branch & accounts ─────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-university am-tab-icon"></i>
Accounts
@if (tabHasError(2)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro">
<h3>Accounts</h3>
<p>The ledger account the agent's cash passes through, and the savings account commission is paid into.</p>
</div>
<div class="am-grid">
<!-- The account this agent's cash sits in between collecting it and bringing it in, so a
balance left standing on it is this agent's and nobody else's. Required, and one agent
to an account - an account already taken is refused by name, not by number. Only
detail accounts are offered: a header account is a heading over others and carries no
entries of its own. -->
<mat-form-field appearance="outline" class="am-field am-field--full">
<mat-label>GL Account</mat-label>
<mat-select
required
formControlName="glAccountId"
(openedChange)="!$event && glAccountSearch.setValue('')"
>
<!-- Type a code or a name to narrow the list; cleared again when the list closes. -->
<mat-option>
<ngx-mat-select-search
placeholderLabel="Search by code or name"
noEntriesFoundLabel="No account found"
[formControl]="glAccountSearch"
></ngx-mat-select-search>
</mat-option>
@for (account of filteredGlAccounts; track account.id) {
<mat-option [value]="account.id">{{ account.glCode }} · {{ account.name }}</mat-option>
}
</mat-select>
<mat-hint>This agent's collections pass through it. One agent to an account.</mat-hint>
</mat-form-field>
<!-- Type first, then the number - the order the legacy screen used. The list is built from
the client's own accounts, so every type offered is one they actually hold. -->
<mat-form-field appearance="outline" class="am-field am-field--wide">
<mat-label>Agent Saving A/C Type</mat-label>
<mat-select
required
formControlName="agentSavingAccountType"
(selectionChange)="onSavingsAccountTypePicked()"
>
@for (accountType of savingsAccountTypes; track accountType) {
<mat-option [value]="accountType">{{
savingsAccountTypeCodes[accountType] || accountType
}}</mat-option>
}
</mat-select>
<!-- Two different reasons for an empty list, and the fix differs: enter a client, or
open them a savings account. Saying which saves a guess. -->
@if (savingsAccountTypes.length === 0) {
@if (loadedClientName) {
<mat-hint class="am-hint--error">
{{ loadedClientName }} has no savings account — commission can only be paid into one
</mat-hint>
} @else {
<mat-hint class="am-hint--error"
>Enter a Client ID first — types come from that client's own accounts</mat-hint
>
}
}
</mat-form-field>
<!-- Picked from the client's own accounts, never typed: commission is paid into this
account, and a number that exists only as text is a payment with nowhere to land. -->
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Saving A/C No</mat-label>
<mat-select required formControlName="agentSavingAccountNo" (selectionChange)="onSavingsAccountPicked()">
@for (account of accountsOfChosenType; track account.id) {
<mat-option [value]="account.accountNo">{{ account.accountNo }}</mat-option>
}
</mat-select>
@if (accountsOfChosenType.length === 0) {
<mat-hint>Choose an A/C Type first</mat-hint>
}
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
</mat-form-field>
<!-- How the chosen account stands, read from the account itself: whether it can take the
commission, what it holds, what is on hold and what is left to use. -->
@if (savingsInfoLoading) {
<div class="am-acct am-field--full am-acct--note">Reading the account…</div>
} @else if (savingsInfoFailed) {
<div class="am-acct am-field--full am-acct--note am-acct--error">
Could not read this account's balance just now.
</div>
} @else if (savingsInfo) {
<div class="am-acct am-field--full">
<div class="am-acct__tile">
<span class="am-acct__label">Account Status</span>
<span
class="am-acct__status"
[class.am-acct__status--ok]="savingsInfo.statusClass === 'ok'"
[class.am-acct__status--wait]="savingsInfo.statusClass === 'wait'"
[class.am-acct__status--off]="savingsInfo.statusClass === 'off'"
>
<i class="fas fa-circle"></i>{{ savingsInfo.status }}
</span>
</div>
<div class="am-acct__tile">
<span class="am-acct__label">Balance</span>
<span class="am-acct__value"
>{{ savingsInfo.symbol }}{{ savingsInfo.balance | number: '1.2-2' }}</span
>
</div>
<div class="am-acct__tile">
<span class="am-acct__label">Hold</span>
<span class="am-acct__value" [class.am-acct__value--hold]="savingsInfo.hold > 0">
{{ savingsInfo.symbol }}{{ savingsInfo.hold | number: '1.2-2' }}
</span>
</div>
<div class="am-acct__tile am-acct__tile--accent">
<span class="am-acct__label">Available Balance</span>
<span class="am-acct__value"
>{{ savingsInfo.symbol }}{{ savingsInfo.available | number: '1.2-2' }}</span
>
</div>
</div>
}
</div>
</div>
</mat-tab>
<!-- ── 4. Guarantors ────────────────────────────────────────────────────────────── -->
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-user-shield am-tab-icon"></i>
Guarantors
@if (guarantors.length > 0) {
<span class="am-tab-count">{{ guarantors.length }}</span>
}
@if (tabHasError(3)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro am-tab-intro--split">
<div>
<h3>Guarantor's Detail</h3>
<p>Someone other than the agent who stands behind them. Add as many as are needed.</p>
</div>
<button mat-stroked-button type="button" class="am-add-guarantor" (click)="addGuarantor()">
<i class="fas fa-plus"></i> Add Guarantor
<button
mat-icon-button
type="button"
class="am-remove-guarantor"
(click)="removeGuarantor(i)"
matTooltip="Remove guarantor"
>
<i class="fas fa-trash"></i>
</button>
</div>
}
</div>
@if (guarantors.length === 0) {
<div class="am-empty">
<i class="fas fa-user-shield"></i>
<p class="am-empty-guarantors">No guarantors added yet.</p>
</div>
}
@for (guarantor of guarantors.controls; track guarantor; let i = $index) {
<div class="am-guarantor-row" [formGroup]="$any(guarantor)">
<div class="am-guarantor-head">
<span class="am-guarantor-no">{{ i + 1 }}</span>
<span class="am-guarantor-title">Guarantor {{ i + 1 }}</span>
<button
type="button"
class="am-remove-guarantor"
(click)="removeGuarantor(i)"
matTooltip="Remove guarantor"
aria-label="Remove guarantor"
>
<i class="fas fa-trash"></i>
</button>
</div>
<div class="am-guarantor-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Client ID or Name</mat-label>
<input
matInput
required
formControlName="clientId"
(keydown.enter)="$event.preventDefault(); openGuarantorSearch(guarantor)"
placeholder="Client ID, or search by name or mobile number…"
autocomplete="off"
/>
<button
matSuffix
type="button"
class="am-search-btn"
aria-label="Search customer"
(click)="openGuarantorSearch(guarantor)"
>
<i class="fas fa-search"></i>
</button>
<!-- Shown as soon as it is keyed, not only after leaving the field. -->
@if (guarantor.get('clientId')?.hasError('selfGuarantor')) {
<mat-hint class="am-hint--error">The agent cannot be their own guarantor</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('duplicateGuarantor')) {
<mat-hint class="am-hint--error">This client is already a guarantor above</mat-hint>
} @else if (guarantor.get('clientId')?.hasError('pattern')) {
<mat-hint class="am-hint--error"
>Press search to find this client by name or mobile number</mat-hint
>
}
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Guarantor Name</mat-label>
<input matInput required formControlName="guarantorName" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Telephone</mat-label>
<input matInput formControlName="telephone" mifosxRestrictInput="phone" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Address 1</mat-label>
<input matInput required formControlName="address1" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Address 2</mat-label>
<input matInput formControlName="address2" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>City</mat-label>
<input matInput required formControlName="city" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Pin Code</mat-label>
<input matInput required formControlName="pinCode" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
</div>
}
<div class="am-section">
<div class="am-section__header">
<i class="fas fa-balance-scale"></i>
<h3>Balances</h3>
</div>
</mat-tab>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Current Balance</mat-label>
<input matInput formControlName="agentCurrentBalance" />
</mat-form-field>
<!-- ── 5. Balances ────────────────────────────────────────────────────────────────
Only for an agent already on the books. A new agent always starts at nothing - the form
still sends 0 - so there is nothing to ask; the tab then shows what the agent holds. -->
@if (agentId) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-balance-scale am-tab-icon"></i>
Balances
@if (tabHasError(4)) {
<i class="fas fa-exclamation-circle am-tab-alert" aria-label="Needs attention"></i>
}
</ng-template>
<div class="am-tab-content">
<div class="am-tab-intro">
<h3>Balances</h3>
<p>What the agent holds on the books at the start.</p>
</div>
<div class="am-grid">
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Current Balance</mat-label>
<input matInput formControlName="agentCurrentBalance" />
</mat-form-field>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Balance</mat-label>
<input matInput type="number" formControlName="agentOpeningBalance" />
</mat-form-field>
</div>
</div>
<mat-form-field appearance="outline" class="am-field">
<mat-label>Agent Opening Balance</mat-label>
<input matInput type="number" formControlName="agentOpeningBalance" />
</mat-form-field>
</div>
</div>
</mat-tab>
}
</mat-tab-group>
<div class="am-action-bar">
<div class="am-action-bar__left">
<div class="am-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-stroked-button type="button" (click)="retrieve()">Retrieve</button>
</div>
<div class="am-action-bar__right">
@if (selectedTabIndex > 0) {
<button mat-stroked-button type="button" (click)="selectedTabIndex = selectedTabIndex - 1">
<i class="fas fa-arrow-left am-btn-ic"></i>Previous
</button>
}
@if (selectedTabIndex < tabCount - 1) {
<button mat-stroked-button type="button" color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
Next<i class="fas fa-arrow-right am-btn-ic am-btn-ic--after"></i>
</button>
}
<button
mat-raised-button
color="primary"
@@ -485,6 +313,6 @@
Save
</button>
</div>
</div>
</form>
</form>
</div>
</div>
@@ -7,13 +7,6 @@
*/
$primary: #0f766e;
$primary-dark: #0f4f3c;
$primary-light: #e6f4ee;
$danger: #b3261e;
$border: #e2e8f0;
$text-dark: #0f172a;
$text-mid: #475569;
$text-soft: #94a3b8;
.am-container {
max-width: 1040px;
@@ -21,41 +14,16 @@ $text-soft: #94a3b8;
padding: 24px 16px;
}
// ── Header ──────────────────────────────────────────────────────────────────
.am-header {
display: flex;
align-items: center;
gap: 14px;
gap: 16px;
margin-bottom: 20px;
&__icon {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 46px;
height: 46px;
border-radius: 14px;
background: linear-gradient(135deg, $primary 0%, $primary-dark 100%);
color: #fff;
font-size: 19px;
box-shadow: 0 6px 16px rgb(15 79 60 / 22%);
}
&__text {
min-width: 0;
}
&__title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: $text-dark;
}
&__sub {
margin: 2px 0 0;
font-size: 13px;
color: $text-mid;
font-weight: 500;
}
}
@@ -63,110 +31,40 @@ $text-soft: #94a3b8;
color: $primary;
}
// ── Tabs ────────────────────────────────────────────────────────────────────
.am-tabs {
.am-card {
background: var(--md-sys-color-surface, #fff);
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 2px 10px rgb(15 23 42 / 6%);
overflow: hidden;
::ng-deep {
.mat-mdc-tab-header {
background: #f8fafc;
border-bottom: 1px solid $border;
}
.mat-mdc-tab {
min-width: 100px;
padding: 0 16px;
height: 54px;
}
.mat-mdc-tab .mdc-tab__text-label {
color: $text-mid;
font-weight: 500;
}
.mat-mdc-tab.mdc-tab--active .mdc-tab__text-label {
color: $primary-dark;
font-weight: 600;
}
.mat-mdc-tab .mdc-tab-indicator__content--underline {
border-color: $primary;
border-top-width: 3px;
}
.mat-mdc-tab-body-wrapper {
min-height: 220px;
}
// Floating labels and the date picker's panel open past the edge of a tab; do not clip them.
.mat-mdc-tab-body,
.mat-mdc-tab-body-content {
overflow: visible !important;
}
}
border-radius: 8px;
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
padding: 8px 24px 24px;
}
.am-tab-icon {
margin-right: 8px;
font-size: 14px;
}
.am-section {
padding: 20px 0;
border-bottom: 1px solid rgb(0 0 0 / 8%);
.am-tab-alert {
margin-left: 8px;
font-size: 14px;
color: $danger;
}
.am-tab-count {
display: inline-grid;
place-items: center;
min-width: 20px;
height: 20px;
margin-left: 8px;
padding: 0 6px;
border-radius: 999px;
background: $primary-light;
color: $primary-dark;
font-size: 11px;
font-weight: 700;
}
.am-tab-content {
padding: 22px 24px 8px;
}
.am-tab-intro {
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px dashed $border;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: $text-dark;
&:last-of-type {
border-bottom: none;
}
p {
margin: 3px 0 0;
font-size: 13px;
color: $text-mid;
}
&--split {
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
i {
color: $primary;
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
}
}
// ── Fields ──────────────────────────────────────────────────────────────────
.am-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -179,319 +77,115 @@ $text-soft: #94a3b8;
&--wide {
grid-column: span 2;
}
&--full {
grid-column: 1 / -1;
}
}
// How the account commission is paid into stands: status, balance, hold, available balance.
.am-acct {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
margin-bottom: 8px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
overflow: hidden;
&--note {
display: block;
padding: 14px 18px;
font-size: 13px;
color: $text-mid;
background: #f8fafc;
}
&--error {
color: $danger;
}
&__tile {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-right: 1px solid $border;
&:last-child {
border-right: 0;
}
&--accent {
background: $primary-light;
}
}
&__label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
}
&__value {
font-size: 18px;
font-weight: 700;
color: $text-dark;
&--hold {
color: #b45309;
}
}
&__status {
display: inline-flex;
align-items: center;
gap: 7px;
width: fit-content;
padding: 3px 12px 3px 9px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
background: #f1f5f9;
color: $text-mid;
i {
font-size: 8px;
}
&--ok {
background: #dcfce7;
color: #15803d;
}
&--wait {
background: #fef3c7;
color: #b45309;
}
&--off {
background: #fee2e2;
color: #b91c1c;
}
}
}
@media (width <= 720px) {
.am-acct {
grid-template-columns: repeat(2, minmax(0, 1fr));
&__tile:nth-child(2) {
border-right: 0;
}
&__tile:nth-child(-n + 2) {
border-bottom: 1px solid $border;
}
}
}
// The agent code, shown rather than asked for: it is assigned when the agent is saved.
.am-code {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
min-height: 56px;
margin-bottom: 20px;
padding: 6px 16px;
border: 1px solid #b7ddd0;
border-radius: 4px;
background: $primary-light;
&__label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-mid;
}
&__value {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
color: $primary-dark;
}
&__hint {
font-size: 11.5px;
color: $text-mid;
}
}
/* Client ID lookup came back empty — the officer typed an id that doesn't exist. */
.am-hint--error {
color: $danger;
}
// The search icon is a button: it opens Search Customer.
.am-search-btn {
margin-right: 8px;
padding: 6px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: rgb(0 0 0 / 55%);
font-size: 1rem;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
&:hover:not(:disabled) {
background: $primary-light;
color: $primary-dark;
}
&:disabled {
cursor: default;
opacity: 0.4;
}
}
// ── Guarantors ──────────────────────────────────────────────────────────────
.am-add-guarantor {
color: $primary;
}
.am-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-bottom: 16px;
padding: 28px 16px;
border: 1.5px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
color: $text-soft;
i {
font-size: 28px;
}
}
.am-empty-guarantors {
margin: 0;
color: rgb(0 0 0 / 50%);
font-size: 14px;
color: $text-mid;
margin: 0 0 8px;
}
.am-guarantor-row {
margin-bottom: 16px;
padding: 14px 16px 6px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
}
.am-guarantor-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.am-guarantor-no {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
background: $primary-light;
color: $primary-dark;
font-size: 12px;
font-weight: 700;
grid-template-columns: repeat(7, 1fr) auto;
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
}
.am-guarantor-title {
flex: 1;
font-size: 14px;
font-weight: 600;
color: $text-dark;
}
.am-guarantor-grid {
.am-limit-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px 16px;
grid-template-columns: repeat(3, 1fr) auto;
gap: 8px 12px;
align-items: start;
padding: 10px 0;
border-top: 1px dashed rgb(0 0 0 / 10%);
&:first-of-type {
border-top: none;
}
}
.am-remove-guarantor {
padding: 6px 9px;
border: 0;
border-radius: 8px;
background: transparent;
color: $danger;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: #fef2f2;
}
color: #b91c1c;
margin-top: 8px;
}
// ── Action bar ──────────────────────────────────────────────────────────────
.am-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
flex-wrap: wrap;
margin-top: 18px;
padding: 14px 0;
border-top: 1px solid rgb(0 0 0 / 8%);
&__left,
&__right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
}
.am-btn-ic {
margin-right: 8px;
font-size: 12px;
&--after {
margin-right: 0;
margin-left: 8px;
}
/* Client ID lookup came back empty — the officer typed an id that doesn't exist. */
.am-hint--error {
color: #b3261e;
}
@media (width <= 900px) {
.am-grid,
.am-guarantor-grid {
.am-grid {
grid-template-columns: repeat(2, 1fr);
}
.am-tab-content {
padding: 18px 14px 4px;
.am-guarantor-row,
.am-limit-row {
grid-template-columns: repeat(2, 1fr);
}
}
@media (width <= 560px) {
.am-grid,
.am-guarantor-grid {
grid-template-columns: 1fr;
}
/* One row of the client search list: the id, the name, and the branch - as on the Party form. */
.am-opt {
display: flex;
align-items: baseline;
gap: 10px;
overflow: hidden;
}
.am-field--wide,
.am-field--full {
grid-column: span 1;
}
.am-opt-more {
font-size: 0.75rem;
font-style: italic;
opacity: 0.75;
}
.am-action-bar__left,
.am-action-bar__right {
width: 100%;
justify-content: flex-end;
}
.am-opt-id {
flex: 0 0 auto;
min-width: 52px;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
font-size: 0.78rem;
color: #0f766e;
}
.am-opt-main {
flex: 1 1 auto;
min-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.am-opt-sub {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.75rem;
color: rgb(0 0 0 / 50%);
}
.am-search-ic {
margin-right: 8px;
color: rgb(0 0 0 / 40%);
}
@@ -7,10 +7,9 @@
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import {
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
UntypedFormArray,
Validators,
@@ -27,7 +26,6 @@ import { OrganizationService } from 'app/organization/organization.service';
import { AccountingService } from 'app/accounting/accounting.service';
import { SystemService } from 'app/system/system.service';
import { ClientsService } from 'app/clients/clients.service';
import { SavingsService } from 'app/savings/savings.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { AlertService } from 'app/core/alert/alert.service';
@@ -35,19 +33,10 @@ import { AuthenticationService } from 'app/core/authentication/authentication.se
/** Custom Directives */
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
import { MatDialog } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
import { businessToday } from 'app/core/utils/business-today';
import {
CustomerSearchDialogComponent,
CustomerSearchResult
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
import { MatAutocompleteModule, MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { searchClientsAnyCase } from 'app/deposits/party-master/client-search';
import { officesUnder } from '../offices-under';
/** The chart's account type for a liability: 1 asset, 2 liability, 3 equity, 4 income, 5 expense. */
const AGENT_GL_TYPE_LIABILITY = 2;
/** Writes a value into a form group only when there is something to write. */
function setGroupField(group: UntypedFormGroup, controlName: string, value: any): void {
if (value === null || value === undefined || value === '') {
@@ -78,8 +67,7 @@ function setGroupField(group: UntypedFormGroup, controlName: string, value: any)
...STANDALONE_SHARED_IMPORTS,
// See the Party form: an unimported directive is ignored without a word.
RestrictInputDirective,
MatTabsModule,
NgxMatSelectSearchModule
MatAutocompleteModule
]
})
export class CreateAgentComponent implements OnInit {
@@ -88,14 +76,17 @@ export class CreateAgentComponent implements OnInit {
private route = inject(ActivatedRoute);
private organizationService = inject(OrganizationService);
private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService);
private accountingService = inject(AccountingService);
private systemService = inject(SystemService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
private alertService = inject(AlertService);
private authenticationService = inject(AuthenticationService);
private dialog = inject(MatDialog);
private changeDetector = inject(ChangeDetectorRef);
/** The agent's client field and its list, so a list that arrives late can still be opened. */
@ViewChild('clientTrigger') clientTrigger?: MatAutocompleteTrigger;
@ViewChild('clientInput') clientInput?: ElementRef<HTMLInputElement>;
/**
* The branch the officer is logged in to. An agent is opened there and nowhere else: the Home
@@ -127,6 +118,11 @@ export class CreateAgentComponent implements OnInit {
private static readonly MIN_AGENT_AGE = 16;
private static readonly MAX_AGENT_AGE = 60;
/** The agent's client list without the clients who are already agents. */
private notYetAgents(clients: any[]): any[] {
return clients.filter((client: any) => !this.agentCodeByClientId[String(client.id)]);
}
agentId: number | null = null;
/** Shown under the Client ID field once a client has been resolved. */
@@ -134,99 +130,17 @@ export class CreateAgentComponent implements OnInit {
clientLookupFailed = false;
/**
* Set when what was typed in the Client ID field is not an ID - a name, or a mobile number - so the
* field can say to search for it rather than calling it a client that does not exist.
* Clients to pick from, as on the Party form: the first page when the field is focused, then
* whatever matches what has been typed. An officer knows the agent's name, rarely their client id.
*/
clientNeedsSearch = false;
clientOptions: any[] = [];
private initialClients: any[] = [];
private static readonly CLIENT_LIST_SIZE = 50;
clientTotal = 0;
showingClientList = false;
/**
* The tabs the form is set out over - Agent Details, Address & Contact, Branch & Accounts,
* Guarantors, Balances - and the one showing. Every field is part of the one form whichever tab
* it is on, so moving between tabs loses and changes nothing.
*/
selectedTabIndex = 0;
/** Balances is the fifth tab, and only an agent already on the books has one. */
get tabCount(): number {
return this.agentId ? 5 : 4;
}
/** The controls each tab holds, in tab order, so a tab can say it still needs attention. */
private static readonly TAB_CONTROLS: string[][] = [
[
'officeId',
'clientId',
'agentCode',
'agentNameId',
'agentOpeningDate',
'agentBirthDate'
],
[
'agentAddress1',
'agentAddress2',
'agentCity',
'agentPinCode',
'agentTelephone'
],
[
'glAccountId',
'agentSavingAccountType',
'agentSavingAccountNo'
],
[],
[
'agentCurrentBalance',
'agentOpeningBalance'
]
];
/**
* Whether a tab holds something to put right: a field that is invalid and has been visited, or - on
* the first tab - a client who cannot be made an agent. Display only; Save is decided as before.
*/
tabHasError(index: number): boolean {
const visited = (control: AbstractControl | null) =>
!!control && control.invalid && (control.touched || control.dirty);
if (index === 3) {
return this.guarantors.controls.some((row) => {
const group = row as UntypedFormGroup;
return Object.values(group.controls).some((control) => visited(control));
});
}
const controls = CreateAgentComponent.TAB_CONTROLS[index] ?? [];
const flagged = controls.some((name) => visited(this.agentForm.get(name)));
if (index === 0) {
return (
flagged ||
this.clientNeedsSearch ||
!!this.clientAlreadyAgent ||
!!this.clientAgeProblem ||
this.clientOtherBranch
);
}
return flagged;
}
/** An Indian mobile number is ten digits; a client ID is never that long. */
private static readonly MOBILE_NUMBER_LENGTH = 10;
/**
* The code the next agent is expected to get, shown in place of an Agent Code field - the code is
* assigned by the server when the agent is saved, never keyed. It is worked out the way the server
* does it: "AG" and one more than the highest number among every agent on the books, closed ones
* included. An estimate only - another agent saved first, or one waiting for approval, can take it.
*/
expectedAgentCode = '';
private static nextAgentCode(agents: any[]): string {
let highest = 0;
for (const agent of agents) {
const match = /^AG(\d+)$/.exec(String(agent?.agentCode ?? ''));
if (match) {
highest = Math.max(highest, Number(match[1]));
}
}
return `AG${String(highest + 1).padStart(4, '0')}`;
get clientListIsPartial(): boolean {
return this.showingClientList && this.clientTotal > this.clientOptions.length;
}
/**
@@ -261,31 +175,6 @@ export class CreateAgentComponent implements OnInit {
*/
glAccounts: any[] = [];
/**
* What is typed in the GL Account list's search box. The list narrows to the accounts whose code or
* name contains it, as the account pickers on the product screens do. Only what is shown changes -
* the field still holds the account's id.
*/
glAccountSearch = new UntypedFormControl('');
get filteredGlAccounts(): any[] {
const search = String(this.glAccountSearch.value ?? '')
.trim()
.toLowerCase();
if (!search) {
return this.glAccounts;
}
return this.glAccounts.filter(
(account: any) =>
String(account.glCode ?? '')
.toLowerCase()
.includes(search) ||
String(account.name ?? '')
.toLowerCase()
.includes(search)
);
}
/**
* The savings product a DDS customer's own scheme account is opened under, read from the setting
* that names it rather than guessed from a product name a bank is free to change.
@@ -313,10 +202,9 @@ export class CreateAgentComponent implements OnInit {
clientId: [
'',
// A name typed to search with is not a client; only an id picked or keyed saves.
// Up to nine digits - a mobile number is ten, so one typed in full is not mistaken for an id.
[
Validators.required,
Validators.pattern(/^\d{1,9}$/)
Validators.pattern(/^\d+$/)
]
],
agentCode: [
@@ -358,10 +246,8 @@ export class CreateAgentComponent implements OnInit {
'',
Validators.required
],
// A new agent is opened on the bank's business date, not the computer's clock; it can still be
// changed, and an agent being edited keeps the date it was opened on.
agentOpeningDate: [
businessToday(),
'',
Validators.required
],
// Filled from the client, whose date of birth was checked when they were opened.
@@ -385,11 +271,13 @@ export class CreateAgentComponent implements OnInit {
});
ngOnInit(): void {
// A client already keyed is checked against a different branch when that is chosen.
// A different branch has different clients: the list is read again, and a client already keyed
// is checked against the new branch.
this.agentForm
.get('officeId')
?.valueChanges.pipe(distinctUntilChanged())
.subscribe(() => {
this.initialClients = [];
if (!this.agentId && this.agentForm.get('clientId')?.value) {
this.loadClient();
}
@@ -403,7 +291,6 @@ export class CreateAgentComponent implements OnInit {
this.agentCodeByClientId[String(agent.clientId)] = agent.agentCode;
}
}
this.expectedAgentCode = CreateAgentComponent.nextAgentCode(agents || []);
},
error: () => (this.agentCodeByClientId = {})
});
@@ -438,14 +325,7 @@ export class CreateAgentComponent implements OnInit {
const rows = Array.isArray(accounts) ? accounts : accounts?.pageItems || [];
// usage 1 is a detail account, 2 a header. Disabled accounts refuse every posting, so an
// agent pointed at one would fail on their first collection rather than here.
//
// Liability accounts only (type 2). The cashier credits this account with the cash an agent
// hands over and Apply DDS Receipt debits it as the money is placed on the customers, so what
// stands on it is held for others - a liability. An asset, income or expense account would
// book that money as the bank's own.
this.glAccounts = rows.filter(
(a: any) => a.usage?.id === 1 && !a.disabled && a.type?.id === AGENT_GL_TYPE_LIABILITY
);
this.glAccounts = rows.filter((a: any) => a.usage?.id === 1 && !a.disabled);
},
error: () => (this.glAccounts = [])
});
@@ -462,63 +342,22 @@ export class CreateAgentComponent implements OnInit {
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe((value) => {
const typed = String(value ?? '').trim();
// A name or a mobile number is something to search for, not a client yet. Nothing is looked
// up as the officer types - the search button does that - so the fields stay until one is
// picked, and no client is asked for on a guess.
this.clientNeedsSearch = this.needsSearch(typed);
if (this.clientNeedsSearch) {
// A name is something to search for, not a client yet - the fields stay until one is picked.
if (typed && !/^\d+$/.test(typed)) {
this.searchClients(typed);
return;
}
if (!typed) {
this.showClientList();
}
this.loadClient();
// A guarantor row that was fine may now be the agent itself, or the other way round.
for (const row of this.guarantors.controls) {
row.get('clientId')?.updateValueAndValidity();
}
});
}
/**
* Whether what was typed is something to search for rather than a client ID: letters, which make it
* a name, or digits as long as a mobile number.
*/
private needsSearch(typed: string): boolean {
if (!typed) {
return false;
}
return !/^\d+$/.test(typed) || typed.length >= CreateAgentComponent.MOBILE_NUMBER_LENGTH;
}
/**
* Opens Search Customer - the same dialog, and the same customer search API, the cash screens use -
* to find the agent's client by ID, name or mobile number, and puts the one picked into the field,
* which then fills the rest as an ID keyed in would.
*/
openClientSearch(): void {
if (this.agentId) {
return;
}
this.openCustomerSearch((result) => this.agentForm.get('clientId')?.setValue(String(result.clientId)));
}
/** The same search for a guarantor row, which fills that row from the client picked. */
openGuarantorSearch(group: AbstractControl): void {
this.openCustomerSearch((result) => group.get('clientId')?.setValue(String(result.clientId)), group);
}
private openCustomerSearch(picked: (result: CustomerSearchResult) => void, row?: AbstractControl): void {
const typed = String((row ?? this.agentForm).get('clientId')?.value ?? '').trim();
this.dialog
.open(CustomerSearchDialogComponent, {
width: '960px',
maxWidth: '95vw',
// An agent or guarantor is a customer first - they need not hold an account to be picked, and
// are listed once however many accounts they have. What was already typed is searched for.
data: { requireAccount: false, uniqueCustomers: true, prefill: typed }
})
.afterClosed()
.subscribe((result: CustomerSearchResult | undefined) => {
if (result?.clientId) {
picked(result);
// And the agent's own client leaves (or rejoins) each row's list.
if (!row.get('clientId')?.value) {
this.setGuarantorOptions(row, this.eligibleGuarantors(row, this.initialClients));
}
}
});
}
@@ -533,7 +372,7 @@ export class CreateAgentComponent implements OnInit {
'',
[
Validators.required,
Validators.pattern(/^\d{1,9}$/),
Validators.pattern(/^\d+$/),
this.notTheAgentItself(),
this.notAlreadyAGuarantor()
]
@@ -559,16 +398,26 @@ export class CreateAgentComponent implements OnInit {
});
// A guarantor is a client too, so its row fills from the client the same way the agent's does.
// A keyed id fills the row; a name or mobile number is found with the row's search button.
// Searched by name like the agent's own client field; a picked or keyed id fills the row.
group
.get('clientId')
?.valueChanges.pipe(debounceTime(500), distinctUntilChanged())
.subscribe((value) => {
const typed = String(value ?? '').trim();
if (this.needsSearch(typed)) {
if (typed && !/^\d+$/.test(typed)) {
searchClientsAnyCase(this.clientsService, typed).subscribe((found: any[]) => {
this.setGuarantorOptions(group, this.eligibleGuarantors(group, found).slice(0, 25));
const panel = (group as any).panel;
this.openWhenFocused(panel?.trigger, panel?.input);
});
return;
}
this.setGuarantorOptions(group, []);
this.loadGuarantorClient(group, typed);
if (!typed) {
// Emptied: the list comes back rather than leaving nothing to pick from.
this.onGuarantorFocus(group);
}
// Two rows naming the same client: the other row's check has to be run again too.
for (const row of this.guarantors.controls) {
if (row !== group) {
@@ -580,6 +429,59 @@ export class CreateAgentComponent implements OnInit {
return group;
}
/**
* Each guarantor row's list lives on the row itself, as a plain array the template reads - the same
* way the agent's own list works - so one row's typing never fills another's.
*/
private setGuarantorOptions(group: AbstractControl, options: any[]): void {
(group as any).clientOptions = options;
}
guarantorOptionsFor(group: AbstractControl): any[] {
return (group as any).clientOptions || GUARANTOR_NO_OPTIONS;
}
/**
* Opens the client list on a guarantor row as soon as it is focused, as the agent's field does.
* The list is filled before the panel is asked to open, since a panel opened empty stays shut.
*/
onGuarantorFocus(group: AbstractControl, trigger?: MatAutocompleteTrigger, input?: HTMLElement): void {
if (trigger) {
// Kept so a search answered later can open this row's list too.
(group as any).panel = { trigger, input };
}
if (group.get('clientId')?.value) {
return;
}
const show = () => {
this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients));
const panel = (group as any).panel;
this.openWhenFocused(panel?.trigger, panel?.input);
};
if (this.initialClients.length > 0) {
show();
return;
}
this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
show();
},
error: () => undefined
});
}
/** Clients who can stand guarantor on this row: not the agent, and not already on another row. */
private eligibleGuarantors(group: AbstractControl, found: any[]): any[] {
const agentClientId = String(this.agentForm.get('clientId')?.value ?? '');
const taken = new Set(
this.guarantors.controls.filter((row) => row !== group).map((row) => String(row.get('clientId')?.value ?? ''))
);
return found.filter((client: any) => String(client.id) !== agentClientId && !taken.has(String(client.id)));
}
/** The same client cannot guarantee one agent twice - it counts as one guarantee, not two. */
private notAlreadyAGuarantor(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
@@ -649,6 +551,8 @@ export class CreateAgentComponent implements OnInit {
addGuarantor(): void {
const group = this.buildGuarantorGroup();
this.guarantors.push(group);
// Ready before the field is ever focused, so the list is there the moment it is clicked.
this.setGuarantorOptions(group, this.eligibleGuarantors(group, this.initialClients));
}
removeGuarantor(index: number): void {
@@ -669,6 +573,60 @@ export class CreateAgentComponent implements OnInit {
'agentPinCode'
];
/** Opens the client list when the field is focused, unless a client is already in it. */
onClientFocus(): void {
if (this.agentId || this.agentForm.get('clientId')?.value) {
return;
}
this.showClientList();
}
/** Shows the first page of clients, fetching it the first time it is asked for. */
private showClientList(): void {
if (this.agentId) {
return;
}
if (this.initialClients.length > 0) {
this.showingClientList = true;
this.clientOptions = this.notYetAgents(this.initialClients);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
return;
}
// The chosen branch's clients only, since an agent is opened for the branch they belong to.
this.clientsService.getFilteredClients('displayName', 'ASC', false, '', this.branchOfficeId).subscribe({
next: (response: any) => {
const found = response?.pageItems || response || [];
this.initialClients = Array.isArray(found) ? found.slice(0, CreateAgentComponent.CLIENT_LIST_SIZE) : [];
this.clientTotal = response?.totalFilteredRecords ?? this.initialClients.length;
this.showingClientList = true;
this.clientOptions = this.notYetAgents(this.initialClients);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
},
// A failed list is not a failed screen - the id can still be typed.
error: () => (this.clientOptions = [])
});
}
private searchClients(term: string): void {
searchClientsAnyCase(this.clientsService, term, this.branchOfficeId).subscribe((found: any[]) => {
this.showingClientList = false;
this.clientOptions = this.notYetAgents(found).slice(0, 25);
this.openWhenFocused(this.clientTrigger, this.clientInput?.nativeElement);
});
}
/**
* Shows a list that has just arrived. An autocomplete opened while its list was still empty stays
* shut when the options come in, so the screen is refreshed and the panel opened again - but only
* on the field still being typed in, never one the officer has moved on from.
*/
private openWhenFocused(trigger?: MatAutocompleteTrigger, input?: HTMLElement): void {
this.changeDetector.detectChanges();
if (trigger && input && document.activeElement === input) {
trigger.openPanel();
}
}
/**
* Refills the client-derived fields whenever the Client ID changes, as the legacy screen did.
* They always show the client currently entered: changing the id replaces them, clearing it
@@ -687,8 +645,6 @@ export class CreateAgentComponent implements OnInit {
this.savingsAccountTypes = [];
this.agentForm.get('agentSavingAccountType')?.setValue('');
this.agentForm.get('agentSavingAccountNo')?.setValue('');
this.savingsInfo = null;
this.savingsInfoFailed = false;
this.clientOtherBranch = false;
this.clientAlreadyAgent = '';
@@ -766,9 +722,6 @@ export class CreateAgentComponent implements OnInit {
this.savingsAccountTypeCodes[account.productName] = account.shortProductName;
}
}
// An agent being edited already has an account chosen: show how it stands as soon as the
// client's accounts are known.
this.showSavingsAccountInfo(this.agentForm.get('agentSavingAccountNo')?.value);
},
error: () => {
this.clientSavingsAccounts = [];
@@ -785,63 +738,6 @@ export class CreateAgentComponent implements OnInit {
*/
onSavingsAccountTypePicked(): void {
this.agentForm.get('agentSavingAccountNo')?.setValue('');
this.savingsInfo = null;
}
/**
* How the account commission is paid into stands: its status, balance, what is on hold, and what
* is left to use. Read from the account itself, the same figures the cashier's screen shows for it,
* so the officer sees whether it can take the credit before saving rather than when a run fails.
* Display only - nothing here is saved or checked.
*/
savingsInfo: {
status: string;
statusClass: 'ok' | 'wait' | 'off';
symbol: string;
balance: number;
hold: number;
available: number;
} | null = null;
savingsInfoLoading = false;
savingsInfoFailed = false;
onSavingsAccountPicked(): void {
this.showSavingsAccountInfo(this.agentForm.get('agentSavingAccountNo')?.value);
}
private showSavingsAccountInfo(accountNo: string | null | undefined): void {
this.savingsInfo = null;
this.savingsInfoFailed = false;
this.savingsInfoLoading = false;
const match = accountNo ? this.clientSavingsAccounts.find((account: any) => account.accountNo === accountNo) : null;
if (!match) {
return;
}
this.savingsInfoLoading = true;
this.savingsService.getSavingsAccountData(String(match.id)).subscribe({
next: (account: any) => {
// The account may have been swapped for another while this was on its way.
if (this.agentForm.get('agentSavingAccountNo')?.value !== accountNo) {
return;
}
const status = account?.status ?? {};
this.savingsInfo = {
status: status.value || '—',
statusClass: status.active ? 'ok' : status.submittedAndPendingApproval || status.approved ? 'wait' : 'off',
symbol: account?.currency?.displaySymbol ?? '₹',
balance: Number(account?.summary?.accountBalance ?? 0),
// Savings carries a lien or hold as a figure on the account itself, and its available
// balance is already worked out with every hold taken off - as on the cashier's screen.
hold: Number(account?.savingsAmountOnHold ?? 0),
available: Number(account?.summary?.availableBalance ?? account?.summary?.accountBalance ?? 0)
};
this.savingsInfoLoading = false;
},
error: () => {
this.savingsInfoLoading = false;
this.savingsInfoFailed = true;
}
});
}
private loadClientAddress(clientId: string): void {
@@ -894,14 +790,9 @@ export class CreateAgentComponent implements OnInit {
this.agentForm.reset();
this.agentForm.patchValue({
agentCurrentBalance: 0,
agentOpeningBalance: 0,
agentOpeningDate: businessToday()
agentOpeningBalance: 0
});
this.guarantors.clear();
this.savingsInfo = null;
this.savingsInfoFailed = false;
// Back to the first tab, where a fresh entry begins.
this.selectedTabIndex = 0;
}
exit(): void {
@@ -1019,6 +910,9 @@ function asDate(value: any): Date | null {
return null;
}
/** One shared empty list, so a row with nothing to offer hands the template the same array each time. */
const GUARANTOR_NO_OPTIONS: any[] = [];
/** Full years between a date of birth and a day, or null without a date of birth. */
function ageOn(birthDate: Date | null, on: Date): number | null {
if (!birthDate) {
@@ -1,193 +0,0 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="vsp-root va-page">
<button type="button" class="va-back" (click)="back()">
<fa-icon icon="arrow-left"></fa-icon>
<span>All agents</span>
</button>
@if (loading) {
<div class="va-state">Loading agent…</div>
} @else if (notFound || !agent) {
<div class="va-state">This agent could not be found.</div>
} @else {
<!-- HERO -->
<div class="vsp-hero">
<div class="vsp-hero-left">
<div class="vsp-hero-icon"><fa-icon icon="user-tie"></fa-icon></div>
<div class="vsp-hero-text">
<div class="vsp-hero-eyebrow">DDS Agent</div>
<h2 class="vsp-hero-title">{{ displayName }}</h2>
<div class="vsp-hero-meta">
<span class="vsp-pill vsp-pill--code">{{ agent.agentCode }}</span>
@if (agent.homeBranch) {
<span class="vsp-pill vsp-pill--teal">{{ agent.homeBranch }}</span>
}
@if (agent.closeDate) {
<span class="vsp-pill vsp-pill--gray">
<fa-icon icon="times-circle"></fa-icon>
Closed {{ agent.closeDate | dateFormat }}
</span>
} @else {
<span class="vsp-pill" [class.vsp-pill--green]="agent.active" [class.vsp-pill--amber]="!agent.active">
<fa-icon [icon]="agent.active ? 'check-circle' : 'times-circle'"></fa-icon>
{{ agent.active ? 'Active' : 'Inactive' }}
</span>
}
</div>
</div>
</div>
<!-- A closed agent is off the books: nothing can be changed on it, only read. -->
@if (!agent.closeDate) {
<button class="vsp-edit-btn" type="button" [routerLink]="['../', agent.id, 'edit']">
<fa-icon icon="edit"></fa-icon>
<span>Edit</span>
</button>
}
</div>
<!-- DETAILS -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="info-circle"></fa-icon></div>
<h3>Details</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Agent Code</span>
<span class="vsp-v"
><strong>{{ agent.agentCode }}</strong></span
>
</div>
<div class="vsp-kv">
<span class="vsp-k">Client</span>
<span class="vsp-v">{{ agent.clientName || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Agent Name ID</span>
<span class="vsp-v">{{ agent.agentNameId || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Home Branch</span>
<span class="vsp-v">{{ agent.homeBranch || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Opening Date</span>
<span class="vsp-v">{{ agent.agentOpeningDate ? (agent.agentOpeningDate | dateFormat) : '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Birth Date</span>
<span class="vsp-v">{{ agent.agentBirthDate ? (agent.agentBirthDate | dateFormat) : '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Created</span>
<span class="vsp-v">
{{ agent.createdDate ? (agent.createdDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Last Modified</span>
<span class="vsp-v">
{{ agent.lastModifiedDate ? (agent.lastModifiedDate | dateFormat: 'DD MMM YYYY, hh:mm A') : '—' }}
</span>
</div>
</div>
</section>
<!-- ADDRESS & CONTACT -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--amber"><fa-icon icon="map-marker-alt"></fa-icon></div>
<h3>Address &amp; Contact</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv vsp-kv--span">
<span class="vsp-k">Address</span>
<span class="vsp-v">{{ address || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">City</span>
<span class="vsp-v">{{ agent.agentCity || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Pin Code</span>
<span class="vsp-v">{{ agent.agentPinCode || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Telephone</span>
<span class="vsp-v">{{ agent.agentTelephone || '—' }}</span>
</div>
</div>
</section>
<!-- ACCOUNTS & BALANCES -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--green"><fa-icon icon="coins"></fa-icon></div>
<h3>Accounts &amp; Balances</h3>
</div>
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">GL Account</span>
<span class="vsp-v">
@if (agent.glAccountCode) {
{{ agent.glAccountCode }} · {{ agent.glAccountName }}
} @else {
—
}
</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Saving A/C Type</span>
<span class="vsp-v">{{ agent.agentSavingAccountType || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Saving A/C No</span>
<span class="vsp-v">{{ agent.agentSavingAccountNo || '—' }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Current Balance</span>
<span class="vsp-v vsp-v--accent">{{ money(agent.agentCurrentBalance) }}</span>
</div>
<div class="vsp-kv">
<span class="vsp-k">Opening Balance</span>
<span class="vsp-v">{{ money(agent.agentOpeningBalance) }}</span>
</div>
</div>
</section>
<!-- GUARANTORS -->
<section class="vsp-section">
<div class="vsp-section-h">
<div class="vsp-section-ic vsp-section-ic--blue"><fa-icon icon="user-shield"></fa-icon></div>
<h3>Guarantors ({{ agent.guarantors?.length || 0 }})</h3>
</div>
@for (guarantor of agent.guarantors || []; track $index) {
<div class="vsp-grid">
<div class="vsp-kv">
<span class="vsp-k">Guarantor {{ $index + 1 }}</span>
<span class="vsp-v"
><strong>{{ guarantor.guarantorName || '—' }}</strong></span
>
</div>
<div class="vsp-kv">
<span class="vsp-k">Telephone</span>
<span class="vsp-v">{{ guarantor.telephone || '—' }}</span>
</div>
<div class="vsp-kv vsp-kv--span">
<span class="vsp-k">Address</span>
<span class="vsp-v">{{ guarantorAddress(guarantor) || '—' }}</span>
</div>
</div>
} @empty {
<div class="va-empty">No guarantors on file.</div>
}
</section>
}
</div>
@@ -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/.
*/
// Only what the agent view adds to the fixed deposit product sheet it shares: a way back to the list and a
// placeholder while the agent loads.
.va-page {
padding-top: 8px;
}
.va-back {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 6px 12px;
border: 1px solid var(--vsp-g200);
border-radius: 8px;
background: #fff;
color: var(--vsp-g700);
font: inherit;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
&:hover {
background: var(--vsp-g100);
border-color: var(--vsp-g300);
}
}
.va-state {
padding: 32px 0;
text-align: center;
color: var(--vsp-g500);
font-size: 14px;
}
.va-empty {
padding: 14px 0 4px;
color: var(--vsp-g500);
font-size: 13px;
}
@@ -1,103 +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, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from 'app/organization/organization.service';
/**
* A DDS collection agent, read-only: who the agent is, where they live, the accounts they collect against, their
* balances and the guarantors standing behind them. Laid out as the DDS product view is laid out - and drawn by the same
* stylesheet - so every master reads the same way. Edit leads to the same form the list does, and is not offered on an
* agent that has been closed.
*/
@Component({
selector: 'mifosx-view-agent',
templateUrl: './view-agent.component.html',
styleUrls: [
'../../../products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.scss',
'./view-agent.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
RouterLink,
FaIconComponent
]
})
export class ViewAgentComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
agent: any = null;
loading = true;
notFound = false;
ngOnInit(): void {
const id = this.route.snapshot.paramMap.get('agentId');
if (!id) {
this.router.navigate(['/organization/agent-master']);
return;
}
this.organizationService.getAgent(id).subscribe({
next: (agent: any) => {
this.agent = agent;
this.loading = false;
},
error: () => {
this.loading = false;
this.notFound = true;
}
});
}
private static readonly MONEY = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
money(value: any): string {
return ViewAgentComponent.MONEY.format(Number(value || 0));
}
/** The agent's name as the list shows it: the name on file, falling back to the client's. */
get displayName(): string {
return this.agent?.agentNameId || this.agent?.clientName || this.agent?.agentCode || '';
}
/** The address on one line, skipping whatever was left blank. */
get address(): string {
return [
this.agent?.agentAddress1,
this.agent?.agentAddress2,
this.agent?.agentCity,
this.agent?.agentPinCode
]
.filter((part) => !!part)
.join(', ');
}
/** A guarantor's address on one line, skipping whatever was left blank. */
guarantorAddress(guarantor: any): string {
return [
guarantor?.address1,
guarantor?.address2,
guarantor?.city,
guarantor?.pinCode
]
.filter((part) => !!part)
.join(', ');
}
back(): void {
this.router.navigate(['/organization/agent-master']);
}
}
@@ -19,7 +19,6 @@ import { LoanProvisioningCriteriaComponent } from './loan-provisioning-criteria/
import { OfficesComponent } from './offices/offices.component';
import { AgentMasterComponent } from './agent-master/agent-master.component';
import { CreateAgentComponent } from './agent-master/create-agent/create-agent.component';
import { ViewAgentComponent } from './agent-master/view-agent/view-agent.component';
import { CommissionSlabMasterComponent } from './commission-slab-master/commission-slab-master.component';
import { DdsBatchProcessingComponent } from './dds-batch-processing/dds-batch-processing.component';
import { EmployeesComponent } from './employees/employees.component';
@@ -255,11 +254,6 @@ const routes: Routes = [
component: CreateAgentComponent,
data: { title: 'Create Agent', breadcrumb: 'Create' }
},
{
path: ':agentId',
component: ViewAgentComponent,
data: { title: 'View Agent', breadcrumb: 'View', routeParamBreadcrumb: false }
},
{
path: ':agentId/edit',
component: CreateAgentComponent,
@@ -272,11 +272,6 @@ export class OrganizationService {
return this.http.get('/dds/parties');
}
/** The code the next DDS party is expected to get - { partyCode: 'PT0005' } - to show before saving. */
getNextPartyCode(): Observable<any> {
return this.http.get('/dds/parties/nextcode');
}
/**
* The parties a given agent collects for — what the daily transaction screen works from, since
* it handles one agent's round at a time.
@@ -65,6 +65,7 @@ export class PlatformMenuAccessComponent {
{ code: 'MENU_DEPOSITS', label: 'Deposits', icon: 'piggy-bank' },
{ code: 'MENU_ADVANCES', label: 'Advances', icon: 'hand-holding-usd' },
{ code: 'MENU_LOAN_ORIGINATION', label: 'Loan Origination', icon: 'clipboard-check' },
{ code: 'MENU_CREDIT_BUREAU', label: 'Credit Bureau', icon: 'id-card' },
{ code: 'MENU_SHARE', label: 'Share', icon: 'coins' },
{ code: 'MENU_NPA', label: 'NPA', icon: 'exclamation-triangle' },
{ code: 'MENU_GENERAL_LEDGER', label: 'General Ledger', icon: 'book-open' },

Some files were not shown because too many files have changed in this diff Show More