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
179 changed files with 15494 additions and 18877 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(
([
+4 -11
View File
@@ -59,10 +59,7 @@ export class CacheInterceptor implements HttpInterceptor {
'/notes',
// The ALM maturity breakdown counts deposits and loans as of the date asked and reads the bank's
// own bucket edges from the query; a cached copy would show another date's or another setting's figures.
'/almreturn',
// The Approvals badge: a stream that stays open for minutes and a count that moves with every request raised or
// actioned. A copy of either is of no use, and the stream must never be held by anything but its own caller.
'/approvals/counts'
'/almreturn'
];
private isCacheable(url: string): boolean {
@@ -96,9 +93,8 @@ export class CacheInterceptor implements HttpInterceptor {
// Create new response to avoid side-effects
subscriber.next(new HttpResponse(cachedData as Object));
subscriber.complete();
return undefined;
}
const inner = next.handle(request).subscribe(
} else {
next.handle(request).subscribe(
(event) => {
if (environment.httpCacheEnabled && event instanceof HttpResponse) {
this.httpCacheService.setCacheData(request.urlWithParams, event);
@@ -108,10 +104,7 @@ export class CacheInterceptor implements HttpInterceptor {
(error) => subscriber.error(error),
() => subscriber.complete()
);
// The request is cancelled when its caller walks away (a page is left, a screen is closed). The inner request has to
// be cancelled with it: left to run, it kept its connection open. A browser holds about six per site, so a few
// streams that were let go but never closed filled them and every later request waited in the queue.
return () => inner.unsubscribe();
}
});
}
}
@@ -1,16 +0,0 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { HttpContextToken } from '@angular/common/http';
/**
* Per-request opt-out of the top progress bar. Set via `{ context: new HttpContext().set(NO_PROGRESS_BAR, true) }`
* on background calls the user did not ask for (such as the Approvals badge listening for changes), which would
* otherwise keep the bar running or make it flash on a screen that has not changed.
*/
export const NO_PROGRESS_BAR = new HttpContextToken<boolean>(() => false);
@@ -16,7 +16,6 @@ import { finalize } from 'rxjs/operators';
/** Custom Services */
import { ProgressBarService } from './progress-bar.service';
import { NO_PROGRESS_BAR } from './no-progress.token';
/**
* Http Request interceptor to start/stop loading the progress bar.
@@ -30,9 +29,6 @@ export class ProgressInterceptor implements HttpInterceptor {
* and stop when a response or error is received.
*/
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (request.context.get(NO_PROGRESS_BAR)) {
return next.handle(request);
}
this.progressBarService.increase();
return next.handle(request).pipe(
finalize(() => {
+1 -17
View File
@@ -9,7 +9,6 @@
/** Angular Imports */
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
import { Injectable } from '@angular/core';
import { ShellComponent } from '../shell/shell.component';
/**
* A route strategy allowing for explicit route reuse.
@@ -32,22 +31,7 @@ export class RouteReusableStrategy extends RouteReuseStrategy {
return null;
}
/**
* Every feature module registers its own copy of the shell route (Route.withShell), so two pages in different modules
* sit under two different shell routes. Treated as different, a click from one module into another destroyed the
* shell and built a new one: the menu bar vanished until it had loaded again, and the shell's own calls (menu access,
* notifications, business date, the approval stream) went out again on every such click. The shell is the same
* component whichever module it wraps, so it is kept: only the page inside it changes, until a hard refresh.
*/
public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return (
future.routeConfig === curr.routeConfig ||
future.data.reuse ||
(RouteReusableStrategy.isShell(future) && RouteReusableStrategy.isShell(curr))
);
}
private static isShell(route: ActivatedRouteSnapshot): boolean {
return route.routeConfig?.component === ShellComponent;
return future.routeConfig === curr.routeConfig || future.data.reuse;
}
}
+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;
}
+2 -18
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
@@ -81,9 +75,8 @@ export class ShellComponent implements OnInit, OnDestroy {
.pipe(map((result) => result.matches));
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
/** Progress bar mode. It starts from what is running right now: the template shows the bar for anything that is not
* 'none', so an empty start left it running until some request happened to finish. */
progressBarMode: string = this.progressBarService.getRequestsRunning() > 0 ? 'indeterminate' : 'none';
/** Progress bar mode. */
progressBarMode: string;
/** Subscription to progress bar. */
progressBar$: Subscription;
/** Hides nav/sidebar for full-screen cashier routes. */
@@ -151,13 +144,6 @@ export class ShellComponent implements OnInit, OnDestroy {
* Subscribes to progress bar to update its mode.
*/
ngOnInit() {
// The Approvals badge: counts refresh every minute while the app is open, and again whenever the Approval Center opens.
this.approvalTotalSub = this.approvalCounts.total$.subscribe((total) => {
this.approvalTotal = total;
this.cdr.markForCheck();
});
this.approvalCounts.start();
this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => {
this.progressBarMode = mode;
this.cdr.detectChanges();
@@ -401,8 +387,6 @@ export class ShellComponent implements OnInit, OnDestroy {
* Unsubscribes from progress bar.
*/
ngOnDestroy() {
this.approvalTotalSub?.unsubscribe();
this.approvalCounts.stop();
if (this.progressBar$) {
this.progressBar$.unsubscribe();
}
@@ -154,7 +154,7 @@
<fa-icon icon="hand-holding-usd" class="ai-col-h-ic"></fa-icon>
<span>Lending &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>
</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>
}
<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>
</div>
<!-- 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()">
<!--
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>
<button mat-raised-button color="primary" type="button" class="dte-apply-btn" (click)="openApplyReceipt()">
</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: 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-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>
}
<!-- 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.
-->
<!--
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.
-->
@if (isAgentTran && agentInfo.name) {
<div class="dte-notes">
@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,11 +342,7 @@
<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">
<div class="dte-grid">
<mat-form-field appearance="outline" class="dte-field">
<mat-label>Creditor Party Code</mat-label>
<input
@@ -286,10 +356,7 @@
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon
matSuffix
class="dte-list-arrow"
(click)="$event.stopPropagation(); creditorTrigger.openPanel()"
<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)">
@@ -312,13 +379,7 @@
<mat-label>Creditor Curr Bal</mat-label>
<input matInput formControlName="creditorPartyBalance" readonly />
</mat-form-field>
</div>
</div>
<!-- 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
@@ -332,10 +393,7 @@
placeholder="Search by code or name…"
autocomplete="off"
/>
<mat-icon
matSuffix
class="dte-list-arrow"
(click)="$event.stopPropagation(); debitTrigger.openPanel()"
<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)">
@@ -362,8 +420,6 @@
<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>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,
@@ -32,7 +31,6 @@ import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { announceHeldForApproval, heldForApproval } from 'app/deposits/dds-approvals/dds-approval-notice';
import { isDdsClosed } from '../dds-status';
/**
* Daily Transaction Entry component.
@@ -154,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
};
@@ -509,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) {
@@ -545,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: {
@@ -674,7 +642,7 @@ export class DailyTransactionEntryComponent implements OnInit {
this.organizationService.getParties().subscribe({
next: (parties: any) => {
const all = Array.isArray(parties) ? parties : parties?.pageItems || [];
this.openParties = all.filter((party: any) => !isDdsClosed(party.status));
this.openParties = all.filter((party: any) => party.status === 'ACTIVE');
this.fillTransferParty('creditor');
this.fillTransferParty('debit');
},
@@ -831,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;
@@ -846,7 +813,6 @@ export class DailyTransactionEntryComponent implements OnInit {
accountNo: '',
guarantor1: '',
guarantor2: '',
guarantors: [],
currentBalance: 0
};
}
@@ -924,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;
}
@@ -24,7 +24,7 @@
<div class="dav-banner__icon"><fa-icon icon="coins"></fa-icon></div>
<div class="dav-banner__text">
<span class="dav-pill" [ngClass]="'dav-pill--' + (account.status || '').toLowerCase()"
>● {{ statusLabel(account.status) }}</span
>● {{ account.status }}</span
>
<h2 class="dav-banner__title">{{ account.subType }}</h2>
<div class="dav-banner__meta">
@@ -45,67 +45,36 @@
</div>
</div>
<div class="dav-banner__actions">
<button class="dav-icon-btn" (click)="back()" [matTooltip]="backLabel">
<button class="dav-icon-btn" (click)="back()" matTooltip="Back to DDS Accounts">
<fa-icon icon="arrow-left"></fa-icon>
</button>
@if (isOpen) {
<button
class="dav-icon-btn"
[matMenuTriggerFor]="accountMenu"
matTooltip="More actions"
aria-label="More actions"
>
<fa-icon icon="ellipsis-vertical"></fa-icon>
<button class="dav-icon-btn" (click)="openInPartyMaster()" matTooltip="Open in DDS Party Master">
<fa-icon icon="pen"></fa-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="openInPartyMaster()">
<mat-icon><fa-icon icon="pen" size="sm"></fa-icon></mat-icon>
<span>Edit</span>
</button>
@if (canCloseEarly) {
<button mat-menu-item (click)="closeEarly()">
<mat-icon><fa-icon icon="times" size="sm"></fa-icon></mat-icon>
<span>Premature Closure</span>
</button>
}
@if (isMaturityDue) {
<button mat-menu-item (click)="payOut()">
<mat-icon><fa-icon icon="wallet" size="sm"></fa-icon></mat-icon>
<span>Maturity Payout &amp; Close</span>
</button>
}
</mat-menu>
}
</div>
</div>
<!-- Pill-style tab navigation, as the savings account page has it -->
<nav mat-tab-nav-bar class="dav-tabs" [tabPanel]="tabPanel">
<a mat-tab-link class="dav-tab" [active]="tab === 'general'" (click)="tab = 'general'">
<fa-icon icon="info-circle" class="dav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.General' | translate }}</span>
</a>
<a mat-tab-link class="dav-tab" [active]="tab === 'transactions'" (click)="tab = 'transactions'">
<fa-icon icon="exchange-alt" class="dav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Transactions' | translate }}</span>
</a>
<a mat-tab-link class="dav-tab" [active]="tab === 'passbook'" (click)="tab = 'passbook'">
<fa-icon icon="book" class="dav-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Passbook' | translate }}</span>
</a>
<a mat-tab-link class="dav-tab" [active]="tab === 'nominees'" (click)="tab = 'nominees'">
<fa-icon icon="user-shield" class="dav-tab__ic"></fa-icon>
<span>Nominees</span>
</a>
@if (!isOpen) {
<a mat-tab-link class="dav-tab" [active]="tab === 'closure'" (click)="tab = 'closure'">
<fa-icon icon="lock" class="dav-tab__ic"></fa-icon>
<span>Closure</span>
</a>
<!-- Tabs -->
<div class="dav-tabs">
<button class="dav-tab" [class.dav-tab--on]="tab === 'general'" (click)="tab = 'general'">
<fa-icon icon="info-circle"></fa-icon> General
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'transactions'" (click)="tab = 'transactions'">
<fa-icon icon="exchange-alt"></fa-icon> Transactions
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'passbook'" (click)="tab = 'passbook'">
<fa-icon icon="book"></fa-icon> Passbook
</button>
<button class="dav-tab" [class.dav-tab--on]="tab === 'nominees'" (click)="tab = 'nominees'">
<fa-icon icon="user"></fa-icon> Nominees
</button>
@if (account.status === 'CLOSED') {
<button class="dav-tab" [class.dav-tab--on]="tab === 'closure'" (click)="tab = 'closure'">
<fa-icon icon="lock"></fa-icon> Closure
</button>
}
</nav>
</div>
<mat-tab-nav-panel #tabPanel class="dav-body">
<!-- Closure: how a closed account was closed and where its money went -->
@if (tab === 'closure') {
@if (!closure) {
@@ -203,9 +172,7 @@
</div>
}
} @else if (closure.destination === 'SUNDRY') {
<div class="dav-row">
<span>Parked In</span><strong>Sundry (customer has no account here)</strong>
</div>
<div class="dav-row"><span>Parked In</span><strong>Sundry (customer has no account here)</strong></div>
<div class="dav-row">
<span>GL Account</span
><strong class="dav-mono">{{ closure.sundryGl?.glCode }} · {{ closure.sundryGl?.glName }}</strong>
@@ -370,356 +337,170 @@
</div>
}
<!-- Transactions: laid out as the savings account's Transactions tab -->
<!-- Transactions -->
@if (tab === 'transactions') {
<div class="txn-tab">
<div class="txn-tab__card" [hidden]="!hasTransactions">
<div class="txn-tab__toolbar">
<label class="txn-tab__date">
<div class="dav-card">
<div class="dav-toolbar">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</label>
<label class="txn-tab__date">
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</label>
<button mat-stroked-button type="button" class="txn-tab__btn" (click)="showToday()">
<fa-icon icon="clock"></fa-icon> Today
</button>
</div>
<button class="dav-btn" (click)="showToday()"><fa-icon icon="clock"></fa-icon> Today</button>
@if (hasDateFilters) {
<button mat-stroked-button type="button" class="txn-tab__btn" (click)="clearDateFilters()">
<fa-icon icon="times"></fa-icon> {{ 'labels.buttons.Clear' | translate }}
</button>
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<span class="txn-tab__spacer"></span>
<span class="txn-tab__total txn-tab__total--cr"
>Credits {{ totalCredits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}</span
>
<span class="txn-tab__total txn-tab__total--dr"
>Debits {{ totalDebits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}</span
>
<button
mat-flat-button
type="button"
class="txn-tab__export"
(click)="exportCsv()"
[disabled]="shownTransactions.length === 0"
>
{{ 'labels.buttons.Export' | translate }}
<div class="dav-totals">
<span class="dav-cr">Credits ₹{{ totalCredits | formatNumber }}</span>
<span class="dav-dr">Debits ₹{{ totalDebits | formatNumber }}</span>
</div>
<button class="dav-btn dav-btn--primary" (click)="exportCsv()" [disabled]="shownTransactions.length === 0">
Export
</button>
</div>
<table mat-table [dataSource]="txnDataSource" matSort>
<ng-container matColumnDef="row">
<th mat-header-cell *matHeaderCellDef mat-sort-header>#</th>
<td mat-cell *matCellDef="let txn; let idx = index" (click)="viewTransaction(txn)">
{{ idx + 1 }}
</td>
</ng-container>
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Id' | translate }}</th>
<td
mat-cell
*matCellDef="let txn"
class="td-select"
[ngClass]="txnRowClass(txn)"
(click)="viewTransaction(txn)"
>
{{ txn.id }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'labels.inputs.Transaction Date' | translate }}
</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.transactionDate | dateFormat }}
</td>
</ng-container>
<ng-container matColumnDef="transactionType">
<th mat-header-cell *matHeaderCellDef mat-sort-header>
{{ 'labels.inputs.Transaction Type' | translate }}
</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ label(txn.transactionType) }}
</td>
</ng-container>
<ng-container matColumnDef="debit">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Debit' | translate }}</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.direction === 'DEBIT' ? (txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2') : 'N/A' }}
</td>
</ng-container>
<ng-container matColumnDef="credit">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Credit' | translate }}</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.direction === 'CREDIT' ? (txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2') : 'N/A' }}
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Balance' | translate }} ↑</th>
<td mat-cell *matCellDef="let txn" [ngClass]="txnRowClass(txn)" (click)="viewTransaction(txn)">
{{ txn.balanceAfterTransaction || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell class="center" *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell class="center" *matCellDef="let txn">
<button mat-icon-button [matMenuTriggerFor]="txnMenu" aria-label="Transaction actions">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #txnMenu="matMenu">
<button mat-menu-item (click)="viewTransaction(txn)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
<button mat-menu-item (click)="viewTransaction(txn, true)">
<mat-icon><fa-icon icon="receipt" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Receipts' | translate }}</span>
</button>
<button mat-menu-item [disabled]="!txn.journalTransactionId" (click)="viewJournalEntries(txn)">
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="txnColumns"></tr>
<tr mat-row *matRowDef="let row; columns: txnColumns" class="select-row"></tr>
<tr class="mat-mdc-row" *matNoDataRow>
<td class="mat-mdc-cell txn-tab__nodata" [attr.colspan]="txnColumns.length">
Nothing moved on this account in these dates
</td>
</tr>
</table>
<mat-paginator [pageSize]="50" [pageSizeOptions]="[50, 100, 200]" showFirstLastButtons></mat-paginator>
@if (shownTransactions.length === 0) {
<div class="dav-state">
<fa-icon icon="inbox" size="2x"></fa-icon>
<span>{{
hasDateFilters
? 'Nothing moved on this account in these dates'
: 'Nothing has been credited to this account yet'
}}</span>
</div>
<div class="txn-tab__empty" [hidden]="hasTransactions">
<fa-icon icon="inbox" class="txn-tab__empty-ico"></fa-icon>
<div class="txn-tab__empty-title">
{{ 'labels.text.No transaction was found' | translate }}
</div>
<div class="txn-tab__empty-sub">Collections, interest and payouts will appear here.</div>
</div>
</div>
}
<!-- Passbook: laid out as the savings account's Passbook tab - oldest first, the way it is written up -->
@if (tab === 'passbook') {
<div class="passbook-container">
<div class="passbook-filter-bar no-print">
<div class="passbook-filter-form">
<mat-form-field appearance="outline" class="passbook-date-field" subscriptSizing="dynamic">
<mat-label>{{ 'labels.inputs.From Date' | translate }}</mat-label>
<input matInput type="date" [formControl]="fromDateControl" [max]="today" />
</mat-form-field>
<mat-form-field appearance="outline" class="passbook-date-field" subscriptSizing="dynamic">
<mat-label>{{ 'labels.inputs.To Date' | translate }}</mat-label>
<input matInput type="date" [formControl]="toDateControl" [max]="today" />
</mat-form-field>
@if (hasDateFilters) {
<button mat-stroked-button type="button" (click)="clearDateFilters()">
<mat-icon>clear</mat-icon>
{{ 'labels.buttons.Clear' | translate }}
</button>
}
<span class="passbook-filter-spacer"></span>
<button mat-raised-button color="accent" type="button" (click)="printPassbook()">
<mat-icon>print</mat-icon>
{{ 'labels.buttons.Print' | translate }}
</button>
</div>
</div>
<div class="statement-wrapper" id="dds-passbook">
<!-- Bank Header -->
<div class="bank-header">
<div class="bank-logo-area">
<div class="bank-logo-circle">
<mat-icon>account_balance</mat-icon>
</div>
<div class="bank-name-block">
<div class="bank-title">{{ bankName }}</div>
<div class="bank-subtitle">{{ bankSubtitle }}</div>
</div>
</div>
<div class="statement-label">
<div class="stmt-label-text">DDS PASSBOOK</div>
<div class="stmt-period">As on: {{ today | dateFormat }}</div>
</div>
</div>
<!-- Branch line -->
@if (branchName || ifscCode || micrCode) {
<div class="branch-line">
@if (branchName) {
<span>{{ branchName }} Branch</span>
}
@if (branchAddress) {
<span>{{ branchAddress }}</span>
}
@if (ifscCode) {
<span>IFSC: {{ ifscCode }}</span>
}
@if (micrCode) {
<span>MICR: {{ micrCode }}</span>
}
</div>
}
<!-- Account Details -->
<div class="account-details-band">
<div class="account-details-grid">
<div class="detail-block">
<div class="detail-label">Account Holder</div>
<div class="detail-value bold">{{ account.clientName || account.partyName | uppercase }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Account Number</div>
<div class="detail-value mono bold">{{ account.accountNo }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Party Code</div>
<div class="detail-value mono">{{ account.partyCode }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Product</div>
<div class="detail-value">{{ account.subType }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Account Opening Date</div>
<div class="detail-value">{{ account.openingDate | dateFormat }}</div>
</div>
<div class="detail-block">
<div class="detail-label">Status</div>
<div class="detail-value status-badge" [class.active]="account.status === 'ACTIVE'">
{{ statusLabel(account.status) }}
</div>
</div>
<div class="detail-block">
<div class="detail-label">Agent</div>
<div class="detail-value">
{{ account.agentCode }}{{ account.agentName ? ' · ' + account.agentName : '' }}
</div>
</div>
<div class="detail-block">
<div class="detail-label">Interest Rate (p.a.)</div>
<div class="detail-value">{{ account.interestRate }}%</div>
</div>
<div class="detail-block">
<div class="detail-label">Maturity Date</div>
<div class="detail-value">{{ account.maturityDate | dateFormat }}</div>
</div>
</div>
</div>
<!-- Balance Summary Strip -->
<div class="balance-summary-strip">
<div class="balance-box">
<div class="balance-box-label">Current Balance</div>
<div class="balance-box-value">
{{ account.currentBalance || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Maturity Amount</div>
<div class="balance-box-value">
{{ account.maturityAmount || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<div class="balance-divider"></div>
<div class="balance-box">
<div class="balance-box-label">Total Transactions</div>
<div class="balance-box-value">{{ passbookEntries.length }}</div>
</div>
</div>
<!-- Transactions Table -->
@if (passbookEntries.length > 0) {
<table class="statement-table">
} @else {
<div class="dav-table-wrap">
<table class="dav-table">
<thead>
<tr>
<th class="col-sr">#</th>
<th class="col-date">Date</th>
<th class="col-desc">Description</th>
<th class="col-ref">Ref No.</th>
<th class="col-amount text-right">Debit (Dr)</th>
<th class="col-amount text-right">Credit (Cr)</th>
<th class="col-balance text-right">Balance (&#8377;)</th>
<th>#</th>
<th>ID</th>
<th>Transaction Date</th>
<th>Transaction Type</th>
<th>Voucher</th>
<th>Description</th>
<th class="dav-right">Debit</th>
<th class="dav-right">Credit</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of passbookEntries; track txn.id; let i = $index) {
<tr [class.debit-row]="txn.direction === 'DEBIT'" [class.credit-row]="txn.direction === 'CREDIT'">
<td class="col-sr text-center">{{ i + 1 }}</td>
<td class="col-date">{{ txn.transactionDate | dateFormat }}</td>
<td class="col-desc">
<span class="txn-desc">{{ label(txn.transactionType) }}</span>
@for (txn of shownTransactions; track txn.id; let i = $index) {
<tr>
<td>{{ i + 1 }}</td>
<td>{{ txn.id }}</td>
<td>{{ txn.transactionDate | date: 'dd MMMM yyyy' }}</td>
<td>{{ label(txn.transactionType) }}</td>
<td class="dav-mono">{{ txn.voucherNo || '—' }}</td>
<td class="dav-desc">{{ txn.description }}</td>
<td class="dav-right">
{{ txn.direction === 'DEBIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="col-ref text-muted mono">{{ txn.refNo || txn.id }}</td>
<td class="col-amount text-right debit-amount">
@if (txn.direction === 'DEBIT') {
{{ txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
<td class="dav-right">
{{ txn.direction === 'CREDIT' ? '₹' + (txn.amount | formatNumber) : 'N/A' }}
</td>
<td class="dav-right">
<strong>₹{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
}
<!-- Passbook: the same entries oldest first, the way a passbook is written up, and printable -->
@if (tab === 'passbook') {
<div class="dav-card">
<div class="dav-toolbar dav-noprint">
<div class="dav-datepick">
<span>From</span>
<input type="date" [formControl]="fromDateControl" [max]="today" />
</div>
<div class="dav-datepick">
<span>To</span>
<input type="date" [formControl]="toDateControl" [max]="today" />
</div>
@if (hasDateFilters) {
<button class="dav-btn" (click)="clearDateFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
<button class="dav-btn dav-btn--primary dav-push" (click)="printPassbook()">
<fa-icon icon="print"></fa-icon> Print
</button>
</div>
<div class="dav-passbook" id="dds-passbook">
<div class="dav-pb-head">
<div>
<div class="dav-pb-title">DDS Passbook</div>
<div class="dav-pb-sub">{{ account.officeName }}</div>
</div>
<div class="dav-pb-grid">
<span>Account No</span><strong class="dav-mono">{{ account.accountNo }}</strong> <span>Party Code</span
><strong>{{ account.partyCode }}</strong> <span>Name</span
><strong>{{ account.clientName || account.partyName }}</strong> <span>Product</span
><strong>{{ account.subType }} · {{ account.interestRate }} %</strong> <span>Opened On</span
><strong>{{ account.openingDate | date: 'dd/MM/yyyy' }}</strong> <span>Agent</span
><strong>{{ account.agentCode }}</strong>
</div>
</div>
@if (passbookEntries.length === 0) {
<div class="dav-state">
<span>{{ hasDateFilters ? 'No entries in these dates' : 'No entries yet' }}</span>
</div>
} @else {
<table class="dav-table dav-pb-table">
<thead>
<tr>
<th>Date</th>
<th>Voucher</th>
<th>Particulars</th>
<th class="dav-right">Withdrawals</th>
<th class="dav-right">Deposits</th>
<th class="dav-right">Balance</th>
</tr>
</thead>
<tbody>
@for (txn of passbookEntries; track txn.id) {
<tr>
<td>{{ txn.transactionDate | date: 'dd/MM/yyyy' }}</td>
<td class="dav-mono">{{ txn.voucherNo || '' }}</td>
<td>
{{ label(txn.transactionType) }}
@if (txn.description) {
<span class="dav-pb-note">{{ txn.description }}</span>
}
</td>
<td class="col-amount text-right credit-amount">
@if (txn.direction === 'CREDIT') {
{{ txn.amount | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
}
</td>
<td class="col-balance text-right balance-amount">
{{ txn.balanceAfterTransaction || 0 | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
<td class="dav-right">{{ txn.direction === 'DEBIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">{{ txn.direction === 'CREDIT' ? (txn.amount | formatNumber) : '' }}</td>
<td class="dav-right">
<strong>{{ txn.balanceAfterTransaction || 0 | formatNumber }}</strong>
</td>
</tr>
}
</tbody>
<tfoot>
<tr class="total-row">
<td colspan="4" class="text-right total-label">Total / Closing Balance</td>
<td class="text-right total-label debit-amount">
{{ totalDebits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
<tr>
<td colspan="3"><strong>Total</strong></td>
<td class="dav-right">
<strong>{{ totalDebits | formatNumber }}</strong>
</td>
<td class="text-right total-label credit-amount">
{{ totalCredits | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
<td class="dav-right">
<strong>{{ totalCredits | formatNumber }}</strong>
</td>
<td class="text-right closing-balance">
{{ passbookClosingBalance | currency: 'INR' : 'symbol-narrow' : '1.2-2' }}
<td class="dav-right">
<strong>{{ passbookClosingBalance | formatNumber }}</strong>
</td>
</tr>
</tfoot>
</table>
} @else {
<div class="no-data-block">
<mat-icon>inbox</mat-icon>
<p>{{ hasDateFilters ? 'No transactions found for the selected date range.' : 'No entries yet.' }}</p>
</div>
}
<!-- Statement Footer -->
<div class="statement-footer">
<div class="footer-note">
This is a computer-generated statement and does not require a signature. For queries, contact your
branch or call customer care.
</div>
<div class="footer-meta">
Generated on: {{ today | dateFormat }} &nbsp;|&nbsp; Account No: {{ account.accountNo }}
</div>
</div>
</div>
</div>
}
@@ -762,9 +543,7 @@
<td>{{ nominee.nominationDate ? (nominee.nominationDate | date: 'dd MMM yyyy') : '—' }}</td>
<td>{{ nominee.guardianName || '—' }}</td>
<td class="dav-right">
{{
account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—')
}}
{{ account.nomineeSplitMode === 'Sequence' ? nominee.sequenceNo : (nominee.sharePercent ?? '—') }}
</td>
</tr>
}
@@ -774,6 +553,5 @@
}
</div>
}
</mat-tab-nav-panel>
}
</div>
@@ -82,18 +82,6 @@
border-color: rgb(226 232 240 / 40%);
}
.dav-pill--matured {
background: rgb(245 158 11 / 25%);
color: #fde68a;
border-color: rgb(253 230 138 / 40%);
}
.dav-pill--premature_closed {
background: rgb(239 68 68 / 25%);
color: #fecaca;
border-color: rgb(254 202 202 / 40%);
}
.dav-banner__title {
margin: 0;
font-size: 1.25rem;
@@ -169,89 +157,45 @@
}
}
/* ── Tabs: the savings account page's pill tabs ───────────────────────── */
$tab-navy: #0d1b2a;
$tab-blue: #1e88e5;
$tab-blue-d: #1e40af;
$tab-border: #e2e8f0;
/* ── Tabs ─────────────────────────────────────────────────────────────── */
.dav-tabs {
background: #fff;
border: 1px solid $tab-border;
display: flex;
gap: 8px;
padding: 8px;
border-radius: 14px;
padding: 6px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
::ng-deep {
.mat-mdc-tab-link-container {
border-bottom: none !important;
}
.mat-mdc-tab-links {
gap: 4px;
flex-wrap: wrap;
}
.mdc-tab-indicator__content--underline,
.mat-mdc-tab-header-pagination-controls-enabled .mdc-tab-indicator__content--underline {
display: none !important;
}
}
background: #fff;
border: 1px solid rgb(0 0 0 / 7%);
}
.dav-tab {
display: inline-flex !important;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
height: 38px !important;
min-width: 0 !important;
padding: 0 16px !important;
border-radius: 10px !important;
font-family: Inter, 'DM Sans', Roboto, sans-serif !important;
font-size: 13px !important;
font-weight: 600 !important;
letter-spacing: 0.01em !important;
color: #64748b !important;
background: transparent !important;
transition: all 0.15s ease;
white-space: nowrap;
border: 1px solid transparent !important;
opacity: 1 !important;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: 0;
border-radius: 10px;
background: transparent;
font-size: 0.95rem;
font-weight: 600;
color: #475569;
cursor: pointer;
&__ic {
font-size: 12px;
color: inherit;
}
&:hover:not(.mdc-tab--active) {
background: #f1f5f9 !important;
color: $tab-navy !important;
}
&.mdc-tab--active,
&.mat-mdc-tab-link-active {
background: linear-gradient(135deg, $tab-blue 0%, $tab-blue-d 100%) !important;
color: #fff !important;
box-shadow: 0 2px 6px -1px rgb(30 64 175 / 35%);
border-color: $tab-blue-d !important;
::ng-deep .mdc-tab__text-label,
::ng-deep .mat-mdc-tab-link-content,
::ng-deep span,
.dav-tab__ic {
color: #fff !important;
opacity: 1 !important;
}
&:hover {
background: #f1f5f9;
}
}
.dav-body {
display: flex;
flex-direction: column;
gap: 16px;
.dav-tab--on {
color: #fff;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
box-shadow: 0 4px 12px rgb(37 99 235 / 30%);
&:hover {
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
}
/* ── Cards ────────────────────────────────────────────────────────────── */
@@ -325,7 +269,38 @@ $tab-border: #e2e8f0;
color: rgb(0 0 0 / 50%);
}
/* ── Shared tables and buttons (nominees, not-found state) ─────────── */
/* ── Transactions ─────────────────────────────────────────────────────── */
.dav-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid rgb(0 0 0 / 7%);
}
.dav-datepick {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgb(0 0 0 / 45%);
}
input {
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgb(0 0 0 / 15%);
font: inherit;
}
}
.dav-btn {
display: inline-flex;
@@ -344,6 +319,21 @@ $tab-border: #e2e8f0;
}
}
.dav-btn--primary {
color: #fff;
border-color: transparent;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.dav-totals {
margin-left: auto;
display: flex;
gap: 16px;
font-size: 0.88rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.dav-table-wrap {
overflow-x: auto;
}
@@ -376,6 +366,11 @@ $tab-border: #e2e8f0;
}
}
.dav-desc {
max-width: 320px;
color: rgb(0 0 0 / 65%);
}
@media (width <= 720px) {
.dav-page {
padding: 16px;
@@ -387,11 +382,66 @@ $tab-border: #e2e8f0;
}
.dav-tab {
padding: 0 12px !important;
font-size: 12px !important;
font-size: 0.85rem;
padding: 8px;
}
}
/* ── Passbook ─────────────────────────────────────────────────────────── */
.dav-push {
margin-left: auto;
}
.dav-passbook {
padding: 20px;
}
.dav-pb-head {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px 32px;
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 2px solid #1e3a8a;
}
.dav-pb-title {
font-size: 1.2rem;
font-weight: 700;
color: #1e3a8a;
}
.dav-pb-sub {
font-size: 0.85rem;
color: rgb(0 0 0 / 55%);
}
.dav-pb-grid {
display: grid;
grid-template-columns: auto auto;
gap: 4px 16px;
font-size: 0.88rem;
span {
color: rgb(0 0 0 / 55%);
}
}
.dav-pb-table {
tfoot td {
padding: 14px 16px;
border-top: 2px solid rgb(0 0 0 / 15%);
}
}
.dav-pb-note {
display: block;
font-size: 0.78rem;
color: rgb(0 0 0 / 50%);
}
/* Closure tab */
.dav-closure-tag {
margin-left: 8px;
@@ -407,629 +457,3 @@ $tab-border: #e2e8f0;
border-top: 1px solid rgb(0 0 0 / 10%);
font-weight: 600;
}
/* ── Transactions: the savings account's Transactions tab ─────────────── */
$txn-navy: #0d1b2a;
$txn-slate: #475569;
$txn-muted: #64748b;
$txn-border: #e2e8f0;
$txn-soft: #f8fafc;
$txn-blue: #1e40af;
.txn-tab {
width: 100%;
table {
width: 100%;
}
}
.txn-tab__card {
background: #fff;
border: 1px solid $txn-border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
}
.txn-tab__toolbar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 18px;
background: $txn-soft;
border-bottom: 1px solid $txn-border;
flex-wrap: wrap;
}
.txn-tab__date {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: $txn-slate;
font-weight: 500;
input {
height: 34px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid $txn-border;
background: #fff;
font: inherit;
color: $txn-navy;
}
}
.txn-tab__btn {
height: 34px !important;
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 600 !important;
fa-icon {
margin-right: 4px;
}
}
.txn-tab__spacer {
flex: 1;
}
.txn-tab__total {
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.txn-tab__total--cr {
color: #1a7a3f;
}
.txn-tab__total--dr {
color: #c0392b;
}
.txn-tab__export {
height: 36px !important;
border-radius: 8px !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 0 16px !important;
background: linear-gradient(135deg, #1e88e5 0%, $txn-blue 100%) !important;
color: #fff !important;
&:disabled {
opacity: 0.5;
}
}
::ng-deep .txn-tab__card {
.mat-mdc-header-row {
background: $txn-soft;
}
.mat-mdc-header-cell {
font-size: 11.5px;
font-weight: 800;
color: $txn-slate;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 12px 14px;
border-bottom: 1px solid $txn-border;
}
.mat-mdc-cell {
font-size: 13px;
color: $txn-navy;
padding: 11px 14px;
border-bottom: 1px solid #f1f5f9;
}
.mat-mdc-row:hover {
background: #f8fafc;
}
.mat-column-debit,
.mat-column-credit,
.mat-column-balance {
text-align: right;
font-variant-numeric: tabular-nums;
}
.mat-mdc-paginator {
border-top: 1px solid $txn-border;
background: $txn-soft;
min-height: 48px;
}
.transfer {
color: #6d28d9;
}
.txn-tab__nodata {
padding: 32px 14px;
text-align: center;
color: $txn-muted;
}
}
.txn-tab__empty {
background: #fff;
border: 1px solid $txn-border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
padding: 56px 24px;
text-align: center;
color: $txn-muted;
}
.txn-tab__empty-ico {
font-size: 34px;
color: #cbd5e1;
margin-bottom: 8px;
}
.txn-tab__empty-title {
font-size: 14.5px;
font-weight: 700;
color: $txn-slate;
margin-top: 6px;
}
.txn-tab__empty-sub {
font-size: 12.5px;
color: $txn-muted;
margin-top: 2px;
}
/* ── Passbook: the savings account's Passbook tab ─────────────────────── */
$bank-primary: #1a3a6b;
$bank-accent: #c8a951;
$debit-color: #c0392b;
$credit-color: #1a7a3f;
$balance-color: #1a3a6b;
$border-color: #d0d7e3;
$row-alt: #f4f7fb;
.passbook-container {
padding: 16px;
background: #eef1f7;
border-radius: 14px;
min-height: 100%;
}
.passbook-filter-bar {
margin-bottom: 16px;
background: #fff;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid $border-color;
}
.passbook-filter-form {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.passbook-date-field {
width: 180px;
}
.passbook-filter-spacer {
flex: 1;
}
.statement-wrapper {
background: #fff;
border: 1px solid $border-color;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 12px rgb(0 0 0 / 8%);
font-family: 'Segoe UI', Arial, sans-serif;
}
.bank-header {
background: $bank-primary;
padding: 18px 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 4px solid $bank-accent;
}
.bank-logo-area {
display: flex;
align-items: center;
gap: 14px;
}
.bank-logo-circle {
width: 52px;
height: 52px;
border-radius: 50%;
background: $bank-accent;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
color: $bank-primary;
font-size: 28px;
width: 28px;
height: 28px;
}
}
.bank-name-block {
.bank-title {
color: #fff;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.3px;
}
.bank-subtitle {
color: $bank-accent;
font-size: 11px;
font-weight: 500;
letter-spacing: 1.5px;
text-transform: uppercase;
margin-top: 2px;
}
}
.statement-label {
text-align: right;
.stmt-label-text {
color: $bank-accent;
font-size: 13px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
}
.stmt-period {
color: rgb(255 255 255 / 75%);
font-size: 11px;
margin-top: 4px;
}
}
.branch-line {
background: #e8edf5;
border-bottom: 1px solid $border-color;
padding: 6px 24px;
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
font-size: 11px;
color: #4a5a7a;
}
.account-details-band {
background: #f0f3f9;
border-bottom: 1px solid $border-color;
padding: 14px 24px;
}
.account-details-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 24px;
}
.detail-block {
.detail-label {
font-size: 9px;
font-weight: 700;
color: #7a8aaa;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 3px;
}
.detail-value {
font-size: 13px;
color: #222;
&.bold {
font-weight: 600;
}
&.mono {
font-family: 'Courier New', monospace;
letter-spacing: 1px;
}
&.status-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: #eee;
color: #555;
&.active {
background: #d4edda;
color: #155724;
}
}
}
}
.balance-summary-strip {
display: flex;
align-items: stretch;
background: $bank-primary;
padding: 0;
}
.balance-box {
flex: 1;
padding: 12px 20px;
text-align: center;
.balance-box-label {
font-size: 10px;
font-weight: 600;
color: rgb(255 255 255 / 65%);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.balance-box-value {
font-size: 16px;
font-weight: 700;
color: $bank-accent;
}
}
.balance-divider {
width: 1px;
background: rgb(255 255 255 / 15%);
margin: 8px 0;
}
.statement-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
thead tr {
background: #e8edf5;
border-top: 1px solid $border-color;
border-bottom: 2px solid $bank-primary;
}
thead th {
padding: 9px 10px;
font-size: 11px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.4px;
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid #e8ecf3;
transition: background 0.1s;
&:nth-child(even) {
background: $row-alt;
}
&:hover {
background: #dce6f5;
}
}
tbody td {
padding: 7px 10px;
vertical-align: middle;
color: #333;
}
tfoot .total-row {
background: #e8edf5;
border-top: 2px solid $bank-primary;
td {
padding: 9px 10px;
}
}
}
.col-sr {
width: 38px;
}
.col-date {
width: 110px;
white-space: nowrap;
}
.col-desc {
min-width: 160px;
}
.col-ref {
width: 100px;
}
.col-amount {
width: 110px;
}
.col-balance {
width: 120px;
}
.debit-amount {
color: $debit-color;
font-weight: 500;
}
.credit-amount {
color: $credit-color;
font-weight: 500;
}
.balance-amount {
color: $balance-color;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.total-label {
font-size: 12px;
font-weight: 700;
color: $bank-primary;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.closing-balance {
font-size: 14px;
font-weight: 700;
color: $bank-primary;
font-family: 'Courier New', monospace;
}
.txn-desc {
font-weight: 500;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.text-muted {
color: #999;
}
.mono {
font-family: 'Courier New', monospace;
font-size: 11px;
}
.no-data-block {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: #aaa;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 12px;
}
p {
font-size: 14px;
margin: 0;
}
}
.statement-footer {
background: #f0f3f9;
border-top: 1px solid $border-color;
padding: 12px 24px;
text-align: center;
.footer-note {
font-size: 10px;
color: #888;
margin-bottom: 4px;
font-style: italic;
}
.footer-meta {
font-size: 10px;
color: #aaa;
letter-spacing: 0.3px;
}
}
@media (width <= 768px) {
.account-details-grid {
grid-template-columns: 1fr 1fr;
}
.bank-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.statement-label {
text-align: left;
}
.balance-summary-strip {
flex-direction: column;
}
.balance-divider {
width: auto;
height: 1px;
margin: 0 20px;
}
.passbook-filter-form {
flex-direction: column;
align-items: stretch;
}
.passbook-date-field {
width: 100%;
}
.passbook-filter-spacer {
display: none;
}
}
@media (width <= 480px) {
.account-details-grid {
grid-template-columns: 1fr;
}
}
@media print {
.no-print {
display: none !important;
}
.passbook-container {
background: #fff;
padding: 0;
}
.statement-wrapper {
border: none;
box-shadow: none;
border-radius: 0;
}
.bank-header,
.balance-summary-strip {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@@ -6,47 +6,18 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, DestroyRef, OnInit, ViewChild, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { CurrencyPipe, NgClass } from '@angular/common';
import { NgClass } from '@angular/common';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTabLink, MatTabNav, MatTabNavPanel } from '@angular/material/tabs';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatNoDataRow,
MatRow,
MatRowDef,
MatTable,
MatTableDataSource
} from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { MatDialog } from '@angular/material/dialog';
import { DdsTransactionViewDialogComponent } from './dds-transaction-view-dialog/dds-transaction-view-dialog.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { forkJoin, merge, of } from 'rxjs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
import { AlertService } from 'app/core/alert/alert.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { PayoutDialogComponent } from '../../party-master/payout-dialog/payout-dialog.component';
import { ddsStatusLabel, isDdsClosed } from '../../dds-status';
import { PrematureCloseDialogComponent } from '../../party-master/premature-close-dialog/premature-close-dialog.component';
/** What each DDS transaction type is called on screen. */
const TRANSACTION_LABELS: Record<string, string> = {
@@ -56,7 +27,7 @@ const TRANSACTION_LABELS: Record<string, string> = {
MATURITY_PAYOUT: 'Maturity Payout',
PREMATURE_PAYOUT: 'Premature Payout',
PREMATURE_INT_RECOVERY: 'Interest Recovered (Premature)',
PREMATURE_AGENT_TOPUP: 'Interest Funded by Agent',
PREMATURE_AGENT_TOP_UP: 'Interest Funded by Agent',
WITHDRAW_DEPOSITE: 'Transfer (Withdraw Deposite)',
TRANSFER: 'Transfer'
};
@@ -81,29 +52,6 @@ const TRANSACTION_LABELS: Record<string, string> = {
FaIconComponent,
MatTooltipModule,
MatProgressSpinnerModule,
MatTabNav,
MatTabLink,
MatTabNavPanel,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatNoDataRow,
MatSort,
MatSortHeader,
MatPaginator,
MatIcon,
MatIconButton,
MatMenu,
MatMenuItem,
MatMenuTrigger,
CurrencyPipe,
FormatNumberPipe
]
})
@@ -111,11 +59,6 @@ export class DdsAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private organizationService = inject(OrganizationService);
private destroyRef = inject(DestroyRef);
private dialog = inject(MatDialog);
private alertService = inject(AlertService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
isLoading = true;
account: any = null;
@@ -136,40 +79,6 @@ export class DdsAccountViewComponent implements OnInit {
CLOSED_NIL_BALANCE: 'Closed with nil balance'
};
/** Transactions tab table, laid out as the savings account's: oldest first, 50 to a page, sortable. */
readonly txnColumns: string[] = [
'row',
'id',
'date',
'transactionType',
'debit',
'credit',
'balance',
'actions'
];
readonly txnDataSource = new MatTableDataSource<any>([]);
/** The paginator and sort only exist while the Transactions tab is open, so they are re-attached each time. */
@ViewChild(MatPaginator) set txnPaginator(paginator: MatPaginator | undefined) {
this.txnDataSource.paginator = paginator ?? null;
}
@ViewChild(MatSort) set txnSort(sort: MatSort | undefined) {
this.txnDataSource.sort = sort ?? null;
}
/**
* Passbook letterhead - the bank from Institution Profile and the issuing branch from Branch Profile,
* as the savings passbook takes them. Falls back to a generic label when the profile was never filled in.
*/
bankName = 'Bank';
bankSubtitle = 'Account Statement';
branchName = '';
branchAddress = '';
ifscCode = '';
micrCode = '';
/** The passbook reads oldest first, as it is written up. */
get passbookEntries(): any[] {
return [...this.shownTransactions].reverse();
@@ -183,140 +92,37 @@ export class DdsAccountViewComponent implements OnInit {
/**
* Prints the passbook alone. It is copied into a window of its own with only its own styles, so the
* page's menus, banner and tabs do not come out on the paper - the same way the savings passbook prints.
* page's menus, banner and tabs do not come out on the paper.
*/
printPassbook(): void {
const passbook = document.getElementById('dds-passbook');
if (!passbook) {
const popup = window.open('', '_blank', 'width=900,height=700');
if (!passbook || !popup) {
return;
}
const clone = passbook.cloneNode(true) as HTMLElement;
clone.querySelectorAll('.no-print').forEach((el: Element) => el.remove());
// The icon font is not available in the popup: the bank logo becomes an inline SVG, other icons go.
clone.querySelectorAll('mat-icon, .mat-icon').forEach((el: Element) => {
if (el.closest('.bank-logo-circle')) {
el.outerHTML = DdsAccountViewComponent.BANK_LOGO_SVG;
} else {
el.remove();
}
});
const popup = window.open('', '_blank', 'width=1100,height=800');
if (!popup) {
return;
}
popup.document.write(`<html><head><meta charset="utf-8" />
<title>${this.account?.accountNo || 'DDS'} Passbook</title>
<style>${DdsAccountViewComponent.PRINT_STYLES}</style></head>
<body>${clone.outerHTML}</body></html>`);
popup.document.write(`<html><head><title>${this.account?.accountNo || 'DDS'} Passbook</title><style>
body { font-family: Arial, sans-serif; margin: 24px; color: #111; }
.dav-pb-head { display: flex; justify-content: space-between; gap: 24px; margin-bottom: 16px; }
.dav-pb-title { font-size: 20px; font-weight: 700; }
.dav-pb-sub { color: #555; }
.dav-pb-grid { display: grid; grid-template-columns: auto auto; gap: 2px 12px; font-size: 13px; }
.dav-pb-grid span { color: #555; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { border: 1px solid #999; padding: 6px 8px; text-align: left; }
.dav-right { text-align: right; }
.dav-pb-note { display: block; font-size: 11px; color: #555; }
</style></head><body>${passbook.innerHTML}</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
/** Material "account_balance" glyph, inlined so the print window needs no icon font. */
private static readonly BANK_LOGO_SVG = `
<svg class="bank-logo-svg" viewBox="0 0 24 24" width="26" height="26" aria-hidden="true">
<path d="M4 10h3v7H4zM10.5 10h3v7h-3zM2 19h20v3H2zM17 10h3v7h-3zM12 1L2 6v2h20V6z" />
</svg>`;
/** Mirrors the passbook styles in the component's SCSS - scoped styles do not reach the popup. */
private static readonly PRINT_STYLES = `
@page { size: A4 portrait; margin: 10mm; }
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { font-family: 'Segoe UI', Arial, sans-serif; color: #333; background: #fff; }
.statement-wrapper { background: #fff; border: 1px solid #d0d7e3; border-radius: 6px; overflow: hidden; }
.bank-header { background: #1a3a6b; padding: 14px 20px; display: flex; justify-content: space-between;
align-items: center; border-bottom: 4px solid #c8a951; }
.bank-logo-area { display: flex; align-items: center; gap: 12px; }
.bank-logo-circle { width: 44px; height: 44px; border-radius: 50%; background: #c8a951; display: flex;
align-items: center; justify-content: center; }
.bank-logo-svg { fill: #1a3a6b; display: block; }
.bank-title { color: #fff; font-size: 16px; font-weight: 700; letter-spacing: 0.3px; }
.bank-subtitle { color: #c8a951; font-size: 10px; font-weight: 500; letter-spacing: 1.5px;
text-transform: uppercase; margin-top: 2px; }
.statement-label { text-align: right; }
.stmt-label-text { color: #c8a951; font-size: 12px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; }
.stmt-period { color: rgba(255, 255, 255, 0.75); font-size: 10px; margin-top: 3px; }
.branch-line { background: #e8edf5; border-bottom: 1px solid #d0d7e3; padding: 5px 20px; display: flex;
flex-wrap: wrap; gap: 4px 16px; font-size: 9.5px; color: #4a5a7a; }
.account-details-band { background: #f0f3f9; border-bottom: 1px solid #d0d7e3; padding: 12px 20px; }
.account-details-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 24px; }
.detail-label { font-size: 8.5px; font-weight: 700; color: #7a8aaa; text-transform: uppercase;
letter-spacing: 0.8px; margin-bottom: 2px; }
.detail-value { font-size: 12px; color: #222; }
.detail-value.bold { font-weight: 600; }
.detail-value.mono { font-family: 'Courier New', monospace; letter-spacing: 1px; }
.detail-value.status-badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 10px;
font-weight: 600; background: #eee; color: #555; }
.detail-value.status-badge.active { background: #d4edda; color: #155724; }
.balance-summary-strip { display: flex; align-items: stretch; background: #1a3a6b; }
.balance-box { flex: 1; padding: 10px 18px; text-align: center; }
.balance-box-label { font-size: 9px; font-weight: 600; color: rgba(255, 255, 255, 0.65);
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 3px; }
.balance-box-value { font-size: 15px; font-weight: 700; color: #c8a951; }
.balance-divider { width: 1px; background: rgba(255, 255, 255, 0.15); margin: 6px 0; }
.statement-table { width: 100%; border-collapse: collapse; font-size: 11px; }
.statement-table thead { display: table-header-group; }
.statement-table tfoot { display: table-row-group; }
.statement-table tr { page-break-inside: avoid; break-inside: avoid; }
.statement-table thead tr { background: #e8edf5; border-top: 1px solid #d0d7e3; border-bottom: 2px solid #1a3a6b; }
.statement-table thead th { padding: 8px 9px; font-size: 10px; font-weight: 700; color: #1a3a6b;
text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; text-align: left; }
.statement-table tbody tr { border-bottom: 1px solid #e8ecf3; }
.statement-table tbody tr:nth-child(even) { background: #f4f7fb; }
.statement-table tbody td { padding: 6px 9px; vertical-align: middle; color: #333; }
.statement-table tfoot .total-row { background: #e8edf5; border-top: 2px solid #1a3a6b; }
.statement-table tfoot .total-row td { padding: 8px 9px; }
.col-sr { width: 34px; }
.col-date { width: 100px; white-space: nowrap; }
.col-desc { min-width: 150px; }
.col-ref { width: 90px; }
.col-amount { width: 100px; }
.col-balance { width: 110px; }
.debit-amount { color: #c0392b; font-weight: 500; }
.credit-amount { color: #1a7a3f; font-weight: 500; }
.balance-amount { color: #1a3a6b; font-weight: 600; font-family: 'Courier New', monospace; }
.total-label { font-size: 11px; font-weight: 700; color: #1a3a6b; text-transform: uppercase; letter-spacing: 0.5px; }
.closing-balance { font-size: 13px; font-weight: 700; color: #1a3a6b; font-family: 'Courier New', monospace; }
.txn-desc { font-weight: 500; }
.txn-note { display: block; font-size: 9.5px; color: #777; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-muted { color: #999; }
.mono { font-family: 'Courier New', monospace; font-size: 10px; }
.no-data-block { padding: 40px; text-align: center; color: #aaa; font-size: 13px; }
.statement-footer { background: #f0f3f9; border-top: 1px solid #d0d7e3; padding: 10px 20px; text-align: center; }
.footer-note { font-size: 9px; color: #888; margin-bottom: 3px; font-style: italic; }
.footer-meta { font-size: 9px; color: #aaa; letter-spacing: 0.3px; }
`;
fromDateControl = new FormControl('');
toDateControl = new FormControl('');
readonly today = new Date().toISOString().split('T')[0];
ngOnInit(): void {
this.txnDataSource.sortingDataAccessor = (txn: any, column: string) => {
switch (column) {
case 'date':
return `${txn.transactionDate || ''}#${String(txn.id).padStart(12, '0')}`;
case 'transactionType':
return this.label(txn.transactionType);
case 'voucher':
return txn.voucherNo || '';
case 'debit':
case 'credit':
return Number(txn.amount || 0);
case 'balance':
return Number(txn.balanceAfterTransaction || 0);
default:
return Number(txn.id || 0);
}
};
merge(this.fromDateControl.valueChanges, this.toDateControl.valueChanges)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.refreshTransactionTable());
const partyId = this.route.snapshot.paramMap.get('partyId');
if (!partyId) {
this.isLoading = false;
@@ -327,7 +133,7 @@ export class DdsAccountViewComponent implements OnInit {
transactions: this.organizationService.getPartyTransactionsOfParty(partyId).pipe(catchError(() => of([])))
}).subscribe(({ account, transactions }) => {
this.account = account;
if (isDdsClosed(account?.status)) {
if (account?.status === 'CLOSED') {
this.organizationService.getPartyClosure(partyId).subscribe({
next: (closure: any) => (this.closure = closure),
error: () => (this.closure = null)
@@ -338,39 +144,10 @@ export class DdsAccountViewComponent implements OnInit {
(a: any, b: any) =>
String(b.transactionDate).localeCompare(String(a.transactionDate)) || Number(b.id) - Number(a.id)
);
this.refreshTransactionTable();
this.loadLetterhead(account);
this.isLoading = false;
});
}
/** The Transactions tab reads oldest first, as the savings account's does, within the chosen dates. */
private refreshTransactionTable(): void {
this.txnDataSource.data = this.passbookEntries;
}
/** Institution Profile for the bank; the account's own office's Branch Profile, else the first on record. */
private loadLetterhead(account: any): void {
this.branchName = account?.officeName || '';
forkJoin({
institution: this.organizationService.getInstitutionProfile().pipe(catchError(() => of(null))),
branches: this.organizationService.getBranchProfiles().pipe(catchError(() => of([])))
}).subscribe(({ institution, branches }: any) => {
if (institution) {
this.bankName = institution.passbookHeaderName || institution.bankName || institution.legalName || 'Bank';
this.bankSubtitle = institution.licenseNumber ? `Lic. No. ${institution.licenseNumber}` : 'Account Statement';
}
const list: any[] = Array.isArray(branches) ? branches : [];
const branch = list.find((b: any) => account?.officeId && b.officeId === account.officeId) || list[0];
if (branch) {
this.branchName = branch.officeName || this.branchName;
this.branchAddress = branch.address || '';
this.ifscCode = branch.ifscCode || '';
this.micrCode = branch.micrCode || '';
}
});
}
/** The transactions in the chosen range - all of them when no range is set. */
get shownTransactions(): any[] {
const from = this.fromDateControl.value;
@@ -387,11 +164,6 @@ export class DdsAccountViewComponent implements OnInit {
});
}
/** Whether the account has any transactions at all, whatever the date filter says. */
get hasTransactions(): boolean {
return this.transactions.length > 0;
}
get totalCredits(): number {
return this.sumOf('CREDIT');
}
@@ -421,7 +193,7 @@ export class DdsAccountViewComponent implements OnInit {
return (
this.totalOf('INTEREST_POST') +
this.totalOf('OD_INTEREST_POST') +
this.totalOf('PREMATURE_AGENT_TOPUP') -
this.totalOf('PREMATURE_AGENT_TOP_UP') -
recovered
);
}
@@ -437,11 +209,6 @@ export class DdsAccountViewComponent implements OnInit {
return this.transactions.length > 0 ? this.transactions[0].transactionDate : null;
}
/** Row tint, as the savings transactions table has it - money moved by transfer shows in violet. */
txnRowClass(txn: any): string {
return txn.transactionType === 'TRANSFER' || txn.transactionType === 'WITHDRAW_DEPOSITE' ? 'transfer' : '';
}
/** The rows on screen, as a spreadsheet the branch can keep or send on. */
exportCsv(): void {
const header = [
@@ -501,37 +268,6 @@ export class DdsAccountViewComponent implements OnInit {
this.toDateControl.setValue('');
}
/**
* A line in full - what it was, when, the payment details, voucher, reference, journal, branch and
* who keyed it - as a savings account's View Transaction shows one. From the Receipt menu item it
* prints straight away.
*/
viewTransaction(txn: any, printReceipt = false): void {
this.dialog.open(DdsTransactionViewDialogComponent, {
width: '640px',
maxWidth: '95vw',
data: {
transaction: txn,
account: this.account,
typeLabel: this.label(txn.transactionType),
printOnOpen: printReceipt
}
});
}
viewJournalEntries(txn: any): void {
if (txn.journalTransactionId) {
this.router.navigate([
'/',
'journal-entry',
'view',
txn.journalTransactionId
]);
}
}
readonly statusLabel = ddsStatusLabel;
label(type: string): string {
return TRANSACTION_LABELS[type] || String(type || '').replace(/_/g, ' ');
}
@@ -540,29 +276,16 @@ export class DdsAccountViewComponent implements OnInit {
switch (this.account?.status) {
case 'ACTIVE':
return 'status--active';
case 'MATURED':
return 'status--default';
case 'CLOSED':
case 'PREMATURE_CLOSED':
return 'status--closed';
default:
return 'status--default';
}
}
get backLabel(): string {
return this.route.snapshot.queryParamMap.has('agent') ? "Back to the agent's accounts" : 'Back to DDS Accounts';
}
/** Opened from an agent's page in Party Master, Back returns to that agent; otherwise to the DDS Accounts list. */
back(): void {
const agent = this.route.snapshot.queryParamMap.get('agent');
if (agent) {
this.router.navigate(['/accounts/dds'], { queryParams: { agent } });
} else {
this.router.navigate(['/accounts/dds-accounts']);
}
}
openInPartyMaster(): void {
this.router.navigate([
@@ -571,127 +294,4 @@ export class DdsAccountViewComponent implements OnInit {
'edit'
]);
}
get isOpen(): boolean {
return !!this.account && !isDdsClosed(this.account.status);
}
/**
* Closing before the term is up - only while the maturity date is ahead. A matured account is paid
* out and closed on its own terms, and the server refuses an early close on it.
*/
get canCloseEarly(): boolean {
const at = this.maturityAt;
return this.isOpen && this.account.status === 'ACTIVE' && !!at && at.getTime() > Date.now();
}
private get closeDateFields(): { closeDate: string; dateFormat: string; locale: string } {
const dateFormat = this.settingsService.dateFormat;
return {
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
dateFormat,
locale: this.settingsService.language.code
};
}
/** The term is up: the maturity date has come, whether or not the nightly job has marked it MATURED. */
get isMaturityDue(): boolean {
if (!this.isOpen) {
return false;
}
if (this.account.status === 'MATURED') {
return true;
}
const at = this.maturityAt;
return !!at && at.getTime() <= Date.now();
}
private get maturityAt(): Date | null {
const maturity = this.account?.maturityDate;
if (!maturity) {
return null;
}
const at = Array.isArray(maturity) ? new Date(maturity[0], maturity[1] - 1, maturity[2]) : new Date(maturity);
return isNaN(at.getTime()) ? null : at;
}
/**
* Pays a matured account out. The server closes it (status CLOSED) in the same step, as a fixed
* deposit is closed on its maturity payout - there is no separate close.
*/
payOut(): void {
if (!this.isMaturityDue) {
return;
}
const account = this.account;
const dialogRef = this.dialog.open(PayoutDialogComponent, { data: account, width: '520px', autoFocus: false });
dialogRef.afterClosed().subscribe((payoutCommand: any) => {
if (!payoutCommand) {
return;
}
this.organizationService.payOutParty(account.id, payoutCommand).subscribe((result: any) => {
const awaitingApproval = !!result?.commandId && result?.resourceId == null;
this.alertService.alert({
type: awaitingApproval ? 'Sent for Approval' : 'Paid Out & Closed',
message: awaitingApproval
? `Maturity payout of ${account.partyCode} is waiting for a manager's approval. Nothing has been paid yet.`
: `${account.partyCode} is paid out and closed.`
});
this.back();
});
});
}
/**
* Closing early. The dialog asks whether the customer or the agent carries the cost and shows what
* the scheme charges for each; the lock-in and everything else is applied by the server.
*/
closeEarly(): void {
if (!this.canCloseEarly) {
return;
}
const account = this.account;
const dialogRef = this.dialog.open(PrematureCloseDialogComponent, {
data: {
partyCode: account.partyCode,
partyName: account.partyName,
subType: account.subType,
interestRate: account.interestRate,
maturityDate: account.maturityDate,
currentBalance: account.currentBalance,
openingDate: account.openingDate,
clientId: account.clientId,
agentCode: account.agentCode,
// For the payout calculation the dialog shows.
id: account.id,
agentId: account.agentId,
interestCalc: account.interestCalc,
collectedToDate: account.collectedToDate
},
width: '960px',
maxWidth: '96vw',
autoFocus: false
});
dialogRef.afterClosed().subscribe((closeCommand: any) => {
if (!closeCommand) {
return;
}
// Inside the lock-in it is a request with a reason, on its own permission, for a manager to pass.
const request = { ...closeCommand, ...this.closeDateFields };
(closeCommand.lockinOverrideReason
? this.organizationService.prematureCloseOverrideParty(account.id, request)
: this.organizationService.prematureCloseParty(account.id, request)
).subscribe((response: any) => {
// Under maker-checker the command is only recorded, and nothing has closed yet.
const awaitingApproval = !!response?.commandId && response?.resourceId == null;
this.alertService.alert({
type: awaitingApproval ? 'Sent for Approval' : 'Party Closed',
message: awaitingApproval
? `Early closure of ${account.partyCode} is waiting for a manager's approval. The account stays open until then.`
: `${account.partyCode} is closed.`
});
this.back();
});
});
}
}
@@ -1,144 +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="dtv">
<header class="dtv-head" [class.dtv-head--debit]="!isCredit">
<div class="dtv-head__ic"><fa-icon [icon]="isCredit ? 'arrow-down' : 'arrow-up'"></fa-icon></div>
<div class="dtv-head__text">
<h2>{{ data.typeLabel }}</h2>
<p>Transaction #{{ txn.id }} · {{ txn.refNo || '—' }}</p>
</div>
<div class="dtv-head__amount">
<span>{{ isCredit ? 'Credit' : 'Debit' }}</span>
<strong>{{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}</strong>
</div>
<button type="button" class="dtv-close" (click)="close()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</header>
<div class="dtv-body" id="dds-txn-receipt">
<!-- What the printed receipt opens with; hidden on screen, where the header above says it. -->
<div class="dtv-print-only">
<h2>DDS Transaction Receipt</h2>
<div class="muted">
{{ account.partyName }} · A/c {{ account.accountNo }} · {{ txn.officeName || account.officeName || '' }}
</div>
<div class="amount">
{{ isCredit ? 'Credit' : 'Debit' }} {{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}
</div>
</div>
<section class="dtv-section">
<h3>General</h3>
<table class="dtv-kv">
<tr>
<td class="k">Id</td>
<td>{{ txn.id }}</td>
</tr>
<tr>
<td class="k">Reference No.</td>
<td class="mono">{{ txn.refNo || '—' }}</td>
</tr>
<tr>
<td class="k">Transaction Type</td>
<td>{{ data.typeLabel }} ({{ txn.direction }})</td>
</tr>
<tr>
<td class="k">Transaction Date</td>
<td>{{ txn.transactionDate | date: 'dd MMM yyyy' }}</td>
</tr>
<tr>
<td class="k">Submitted On</td>
<td>{{ txn.submittedOnDate ? (txn.submittedOnDate | date: 'dd MMM yyyy') : '—' }}</td>
</tr>
<tr>
<td class="k">Currency</td>
<td>{{ currency }}</td>
</tr>
<tr>
<td class="k">Amount</td>
<td class="strong">{{ txn.amount | currency: currency : 'symbol-narrow' : '1.2-2' }}</td>
</tr>
<tr>
<td class="k">Running Balance</td>
<td class="strong">
{{ txn.balanceAfterTransaction || 0 | currency: currency : 'symbol-narrow' : '1.2-2' }}
</td>
</tr>
<tr>
<td class="k">Note</td>
<td>{{ txn.description || '—' }}</td>
</tr>
</table>
</section>
<section class="dtv-section">
<h3>Payment Details</h3>
<table class="dtv-kv">
<tr>
<td class="k">Mode</td>
<td>{{ txn.transactionMode || '—' }}</td>
</tr>
<tr>
<td class="k">Account Type / No.</td>
<td>{{ txn.accountType || '—' }}{{ txn.accountNo ? ' · ' + txn.accountNo : '' }}</td>
</tr>
<tr>
<td class="k">Cheque No.</td>
<td>{{ txn.chequeNo || '—' }}</td>
</tr>
<tr>
<td class="k">Token No.</td>
<td>{{ txn.tokenNo || '—' }}</td>
</tr>
</table>
</section>
<section class="dtv-section">
<h3>Accounting &amp; Audit</h3>
<table class="dtv-kv">
<tr>
<td class="k">Voucher No.</td>
<td class="mono">{{ txn.voucherNo || '—' }}</td>
</tr>
<tr>
<td class="k">Journal Entry</td>
<td class="mono">{{ txn.journalTransactionId || 'None - nothing moved on the books' }}</td>
</tr>
<tr>
<td class="k">Branch</td>
<td>{{ txn.officeName || account.officeName || '—' }}</td>
</tr>
<tr>
<td class="k">Agent</td>
<td>{{ txn.agentCode }}{{ txn.agentName ? ' · ' + txn.agentName : '' }}</td>
</tr>
<tr>
<td class="k">Created By</td>
<td>{{ txn.createdByUsername || '—' }}</td>
</tr>
<tr>
<td class="k">Created On</td>
<td>{{ txn.createdDate ? (txn.createdDate | date: 'dd MMM yyyy, hh:mm a') : '—' }}</td>
</tr>
</table>
</section>
</div>
<footer class="dtv-actions">
<button mat-stroked-button type="button" (click)="close()">Close</button>
<span class="dtv-actions__spacer"></span>
<button mat-stroked-button type="button" [disabled]="!txn.journalTransactionId" (click)="viewJournalEntries()">
<fa-icon icon="table-cells"></fa-icon> View Journal Entries
</button>
<button mat-flat-button color="primary" type="button" (click)="printReceipt()">
<fa-icon icon="receipt"></fa-icon> Print Receipt
</button>
</footer>
</div>
@@ -1,167 +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: #1353a0;
$credit: #15803d;
$debit: #b91c1c;
$border: #e2e8f0;
$text: #0f172a;
$text-mid: #475569;
:host {
display: block;
}
.dtv {
display: flex;
flex-direction: column;
max-height: 88vh;
color: $text;
font-size: 13px;
}
.dtv-head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: linear-gradient(135deg, #1e40af, #2563eb);
color: #fff;
&--debit {
background: linear-gradient(135deg, #7f1d1d, #b91c1c);
}
&__ic {
display: grid;
place-items: center;
flex: none;
width: 38px;
height: 38px;
border-radius: 10px;
background: rgb(255 255 255 / 18%);
}
&__text {
flex: 1;
min-width: 0;
h2 {
margin: 0;
font-size: 16px;
font-weight: 700;
}
p {
margin: 2px 0 0;
font-size: 12px;
opacity: 0.85;
}
}
&__amount {
display: flex;
flex-direction: column;
align-items: flex-end;
span {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.85;
}
strong {
font-size: 18px;
}
}
}
.dtv-close {
border: 0;
background: transparent;
color: #fff;
font-size: 16px;
cursor: pointer;
}
.dtv-body {
flex: 1;
min-height: 0;
padding: 14px 20px;
overflow-y: auto;
}
.dtv-print-only {
display: none;
}
.dtv-section {
& + & {
margin-top: 14px;
}
h3 {
margin: 0 0 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: $primary;
}
}
.dtv-kv {
width: 100%;
border: 1px solid $border;
border-radius: 8px;
border-collapse: separate;
border-spacing: 0;
overflow: hidden;
td {
padding: 7px 12px;
border-bottom: 1px solid #eef2f6;
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
.k {
width: 38%;
background: #f8fafc;
color: $text-mid;
font-weight: 600;
}
.mono {
font-family: ui-monospace, 'Cascadia Mono', Consolas, monospace;
}
.strong {
font-weight: 700;
}
}
.dtv-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid $border;
&__spacer {
flex: 1;
}
fa-icon {
margin-right: 6px;
}
}
@@ -1,109 +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 { CurrencyPipe, DatePipe } from '@angular/common';
import { Router } from '@angular/router';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** What the account view hands the dialog: the line, its account, and how to name its type. */
export interface DdsTransactionViewData {
transaction: any;
account: any;
typeLabel: string;
/** Opened from the Receipt menu item: print the receipt straight away. */
printOnOpen?: boolean;
}
/**
* One DDS passbook line in full - everything the line was saved with - the way a savings account's
* View Transaction shows one: what it was, when, how much, and who keyed it, with the voucher, its
* own reference and the journal it was booked under. Prints as a receipt.
*/
@Component({
selector: 'mifosx-dds-transaction-view-dialog',
templateUrl: './dds-transaction-view-dialog.component.html',
styleUrls: ['./dds-transaction-view-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
CurrencyPipe,
DatePipe,
MatButton,
FaIconComponent
]
})
export class DdsTransactionViewDialogComponent {
private dialogRef = inject(MatDialogRef<DdsTransactionViewDialogComponent>);
private router = inject(Router);
data = inject<DdsTransactionViewData>(MAT_DIALOG_DATA);
get txn(): any {
return this.data.transaction || {};
}
get account(): any {
return this.data.account || {};
}
get isCredit(): boolean {
return this.txn.direction === 'CREDIT';
}
get currency(): string {
return this.account.currencyCode || 'INR';
}
constructor() {
if (this.data.printOnOpen) {
// After the view has drawn, so the receipt has something in it to print.
setTimeout(() => this.printReceipt(), 300);
}
}
viewJournalEntries(): void {
if (!this.txn.journalTransactionId) {
return;
}
this.dialogRef.close();
this.router.navigate([
'/',
'journal-entry',
'view',
this.txn.journalTransactionId
]);
}
/** The receipt block alone, in a window of its own - as the savings receipt prints. */
printReceipt(): void {
const receipt = document.getElementById('dds-txn-receipt');
const popup = window.open('', '_blank', 'width=720,height=800');
if (!receipt || !popup) {
return;
}
popup.document.write(`<html><head><title>${this.txn.refNo || 'DDS'} Receipt</title><style>
body { font-family: Arial, sans-serif; font-size: 12px; color: #111; margin: 24px; }
h2 { margin: 0 0 4px; font-size: 16px; } .muted { color: #555; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
td { padding: 6px 8px; border-bottom: 1px solid #ddd; vertical-align: top; }
td.k { width: 38%; color: #555; } .amount { font-size: 18px; font-weight: 700; }
.sign { margin-top: 48px; display: flex; justify-content: space-between; }
</style></head><body>${receipt.innerHTML}
<div class="sign"><span>Customer</span><span>Authorised Signatory</span></div>
</body></html>`);
popup.document.close();
popup.focus();
popup.print();
}
close(): void {
this.dialogRef.close();
}
}
@@ -64,18 +64,22 @@
>
All <span class="dds-status-filter__count">{{ allCount }}</span>
</button>
@for (filter of statusFilters; track filter.value) {
<button
type="button"
class="dds-status-filter__btn"
[class.dds-status-filter__btn--active]="filter.value === 'ACTIVE'"
[class.dds-status-filter__btn--closed]="filter.value === 'CLOSED' || filter.value === 'PREMATURE_CLOSED'"
[class.is-on]="statusFilter === filter.value"
(click)="setStatusFilter(filter.value)"
class="dds-status-filter__btn dds-status-filter__btn--active"
[class.is-on]="statusFilter === 'ACTIVE'"
(click)="setStatusFilter('ACTIVE')"
>
{{ filter.label }} <span class="dds-status-filter__count">{{ countOf(filter.value) }}</span>
Active <span class="dds-status-filter__count">{{ activeCount }}</span>
</button>
<button
type="button"
class="dds-status-filter__btn dds-status-filter__btn--closed"
[class.is-on]="statusFilter === 'CLOSED'"
(click)="setStatusFilter('CLOSED')"
>
Closed <span class="dds-status-filter__count">{{ closedCount }}</span>
</button>
}
</div>
<!-- Date Filters: by the day the account was opened -->
@@ -175,7 +179,7 @@
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--center">Status</th>
<td mat-cell *matCellDef="let row" class="acl-td acl-td--center">
<span class="acl-status" [ngClass]="getStatusClass(row)">{{ statusLabel(row.status) }}</span>
<span class="acl-status" [ngClass]="getStatusClass(row)">{{ row.status }}</span>
</td>
</ng-container>
@@ -31,9 +31,6 @@ import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FormatNumberPipe } from 'app/pipes/format-number.pipe';
import { OrganizationService } from 'app/organization/organization.service';
import { DDS_STATUS_LABELS, ddsStatusLabel } from '../dds-status';
type DdsStatusFilter = 'ALL' | 'ACTIVE' | 'MATURED' | 'CLOSED' | 'PREMATURE_CLOSED';
/**
* The DDS accounts, listed the way Savings Accounts lists savings: account number, client, product,
@@ -96,7 +93,7 @@ export class DdsAccountsComponent implements OnInit, OnDestroy {
private matching: any[] = [];
/** Which accounts are shown: all of them, only the open ones, or only the closed. */
statusFilter: DdsStatusFilter = 'ALL';
statusFilter: 'ALL' | 'ACTIVE' | 'CLOSED' = 'ALL';
/** The page on screen. */
rows: any[] = [];
@@ -116,25 +113,19 @@ export class DdsAccountsComponent implements OnInit, OnDestroy {
return this.matching.length;
}
/** How many accounts the search finds in each status - one button per status. */
countOf(status: string): number {
return this.beforeStatus.filter((account) => account.status === status).length;
get activeCount(): number {
return this.beforeStatus.filter((account) => account.status === 'ACTIVE').length;
}
readonly statusFilters: { value: DdsStatusFilter; label: string }[] = [
{ value: 'ACTIVE', label: DDS_STATUS_LABELS['ACTIVE'] },
{ value: 'MATURED', label: DDS_STATUS_LABELS['MATURED'] },
{ value: 'CLOSED', label: DDS_STATUS_LABELS['CLOSED'] },
{ value: 'PREMATURE_CLOSED', label: DDS_STATUS_LABELS['PREMATURE_CLOSED'] }
];
readonly statusLabel = ddsStatusLabel;
get closedCount(): number {
return this.beforeStatus.filter((account) => account.status === 'CLOSED').length;
}
get allCount(): number {
return this.beforeStatus.length;
}
setStatusFilter(status: DdsStatusFilter): void {
setStatusFilter(status: 'ALL' | 'ACTIVE' | 'CLOSED'): void {
this.statusFilter = status;
this.pageIndex = 0;
this.applyFilters();
@@ -266,7 +257,6 @@ export class DdsAccountsComponent implements OnInit, OnDestroy {
case 'ACTIVE':
return 'status--active';
case 'CLOSED':
case 'PREMATURE_CLOSED':
return 'status--closed';
default:
return 'status--default';
@@ -19,646 +19,12 @@
<span>Loading entry…</span>
</div>
} @else {
<div class="dri-body" [class.ddr-body]="isProductReview || isAgentReview || isPartyReview || isSlabReview">
@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>
</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>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>
@if (group.name === 'Accounting') {
<!--
The ledger accounts the product posts through, laid out as the product's own Accounting
tab lays them out: the kind of account, what it is for, and the account by name and code.
-->
<div class="ddr-acct">
@for (field of group.fields; track field.key) {
<div class="ddr-acct__row" [class.ddr-acct__row--changed]="field.changed">
<div class="ddr-acct__meta">
<span class="ddr-acct__kind" [ngClass]="'ddr-acct__kind--' + glKind(field.key).toLowerCase()">{{
glKind(field.key)
}}</span>
<span class="ddr-acct__label">
{{ field.label }}
@if (field.changed) {
<span class="ddr-tile__flag">Changed</span>
}
</span>
</div>
<div class="ddr-acct__value">
@if (field.changed) {
<span class="ddr-acct__gl ddr-acct__gl--old">
{{ glName(field.current) }}
@if (glCode(field.current)) {
<span class="ddr-acct__code">{{ glCode(field.current) }}</span>
}
</span>
<mat-icon class="ddr-acct__arrow">arrow_forward</mat-icon>
}
<span class="ddr-acct__gl" [class.ddr-acct__gl--none]="!field.value">
{{ glName(field.value) }}
@if (glCode(field.value)) {
<span class="ddr-acct__code">{{ glCode(field.value) }}</span>
}
</span>
</div>
</div>
}
</div>
} @else {
<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 (isSlabReview) {
<!--
A commission slab request reads as the slab: the volume band and the rate it pays, what an
agent earns at the band's start, the change for an edit as old → new, and the whole chart of
that type with this slab in place - so the checker sees it still gives one rate per volume.
-->
<section class="ddr-hero">
<div class="ddr-hero__icon">
<mat-icon>{{ isEdit ? 'edit_note' : 'stacked_bar_chart' }}</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 slab' }}</span>
<span class="dri-status" [ngClass]="statusClass(entry.status)">{{ entry.status }}</span>
</div>
<h2 class="ddr-hero__title">{{ valueOf('slabFrom') || '—' }} – {{ valueOf('slabTo') || '—' }}</h2>
<div class="ddr-hero__code">
<span>{{ valueOf('slabType') || 'Commission' }} slab</span>
@if (entry.resourceId) {
<span class="ddr-dot"></span>
<span class="ddr-mono">#{{ entry.resourceId }}</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>
<section class="ddr-stats ddr-stats--three">
<div class="ddr-stat ddr-stat--accent">
<span class="ddr-stat__label"><mat-icon>percent</mat-icon>Commission</span>
<span class="ddr-stat__value">{{ valueOf('commissionPercent') || '—' }}</span>
<span class="ddr-stat__hint">of the month's collection</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>straighten</mat-icon>Collection band</span>
<span class="ddr-stat__value ddr-stat__value--small"
>{{ valueOf('slabFrom') || '—' }} – {{ valueOf('slabTo') || '—' }}</span
>
<span class="ddr-stat__hint">agent's collection volume</span>
</div>
<div class="ddr-stat">
<span class="ddr-stat__label"><mat-icon>calculate</mat-icon>Example</span>
<span class="ddr-stat__value ddr-stat__value--small">{{ slabExample || '—' }}</span>
<span class="ddr-stat__hint">commission at the band's start</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 - shown below
as current → proposed.
</span>
} @else {
<span>This edit changes nothing - every term matches the slab 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>
<span class="ddr-tile__value">{{ field.value || '—' }}</span>
</span>
} @else {
<span class="ddr-tile__value">{{ field.value || '—' }}</span>
}
</div>
}
</div>
</section>
}
@if (slabChart.length) {
<section class="ddr-section">
<header class="ddr-section__head">
<span class="ddr-section__icon"><mat-icon>table_rows</mat-icon></span>
<span class="ddr-section__title">{{ valueOf('slabType') || 'Commission' }} slab chart</span>
<span class="ddr-slabs__note">{{
entry.status === 'PENDING' ? 'as it will read once approved' : 'as it reads now'
}}</span>
</header>
@if (slabOverlaps) {
<div class="ddr-banner ddr-banner--danger">
<mat-icon>warning</mat-icon>
<span>
This slab's band overlaps
<b>{{ slabOverlaps }} other slab{{ slabOverlaps !== 1 ? 's' : '' }}</b> - a collection in the overlap
would match more than one rate.
</span>
</div>
}
<div class="ddr-slabs">
<div class="ddr-slabs__row ddr-slabs__row--head">
<span>#</span>
<span>Collection from</span>
<span>Collection to</span>
<span class="ddr-slabs__num">Commission</span>
</div>
@for (slab of slabChart; track $index) {
<div
class="ddr-slabs__row"
[class.ddr-slabs__row--requested]="slab.requested"
[class.ddr-slabs__row--overlap]="slab.overlaps"
>
<span class="ddr-mono">
{{ slab.id ? slab.id : 'New' }}
@if (slab.requested) {
<span class="ddr-tile__flag">{{ isEdit ? 'Edited' : 'New' }}</span>
}
@if (slab.overlaps) {
<span class="ddr-slabs__flag">Overlaps</span>
}
</span>
<span>
@if (slab.was && !same(slab.was.from, slab.from)) {
<span class="ddr-tile__old">{{ rupees(slab.was.from) }}</span>
}
{{ rupees(slab.from) }}
</span>
<span>
@if (slab.was && !same(slab.was.to, slab.to)) {
<span class="ddr-tile__old">{{ rupees(slab.was.to) }}</span>
}
{{ rupees(slab.to) }}
</span>
<span class="ddr-slabs__num">
@if (slab.was && !same(slab.was.rate, slab.rate)) {
<span class="ddr-tile__old">{{ percent(slab.was.rate) }}</span>
}
<b>{{ percent(slab.rate) }}</b>
</span>
</div>
}
</div>
</section>
}
} @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 {
<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">
<!-- 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>
@@ -689,42 +55,24 @@
{{ entry.type }} Summary
</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') : '—'
}}
{{ entry.amount !== null && entry.amount !== undefined ? '₹' + (entry.amount | number: '1.2-2') : '—' }}
</span>
</div>
@if (entriesProjected) {
<div class="dri-detail-item dri-detail-item--full">
<span class="dri-detail-label">Ledger</span>
<span class="dri-detail-value"
>No journal entry is made when this is approved - it records what has been earned. The entries below
are what the matching Posting run will book.</span
>
</div>
}
<div class="dri-detail-item">
<span class="dri-detail-label">{{
entriesProjected ? 'DR Accounts (when posted)' : 'DR Accounts'
}}</span>
<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">{{
entriesProjected ? 'CR Accounts (when posted)' : 'CR Accounts'
}}</span>
<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>
@@ -737,17 +85,13 @@
}
</div>
</div>
}
<!-- Debit entries -->
@if (debitLines.length) {
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_upward</mat-icon>
{{ entriesProjected ? 'Debit entries when posted' : 'Debit Entries' }} ({{ debitLines.length }} account{{
debitLines.length !== 1 ? 's' : ''
}}
— funds out)
Debit Entries ({{ debitLines.length }} account{{ debitLines.length !== 1 ? 's' : '' }} — funds out)
</div>
<table class="dri-cr-table">
<thead>
@@ -785,10 +129,7 @@
<div class="dri-card">
<div class="dri-section-title">
<mat-icon>arrow_downward</mat-icon>
{{ entriesProjected ? 'Credit entries when posted' : 'Credit Entries' }} ({{ creditLines.length }} account{{
creditLines.length !== 1 ? 's' : ''
}}
— funds in)
Credit Entries ({{ creditLines.length }} account{{ creditLines.length !== 1 ? 's' : '' }} — funds in)
</div>
<table class="dri-cr-table">
<thead>
@@ -884,10 +225,7 @@
}
@if (entry.status === 'PENDING' && !done) {
<div
class="dri-actions"
[class.ddr-actions]="isProductReview || isAgentReview || isPartyReview || isSlabReview"
>
<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,653 +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;
}
}
}
.ddr-stats--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (width <= 640px) {
.ddr-stats,
.ddr-stats--three {
grid-template-columns: minmax(0, 1fr);
}
}
// ── Commission slab chart ────────────────────────────────────────────────────
.ddr-slabs {
display: flex;
flex-direction: column;
border: 1px solid $border;
border-radius: 12px;
overflow: hidden;
&__note {
margin-left: auto;
font-size: 12px;
color: $text-soft;
}
&__row {
display: grid;
grid-template-columns: 1.1fr 1.4fr 1.4fr 1fr;
gap: 12px;
align-items: center;
padding: 10px 14px;
font-size: 13px;
color: $text-dark;
border-top: 1px solid $border;
&:first-child {
border-top: none;
}
> span {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
&--head {
background: $bg-soft;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: $text-soft;
}
&--requested {
background: $brand-light;
box-shadow: inset 3px 0 0 $brand-mid;
font-weight: 600;
}
&--overlap {
background: #fef2f2;
box-shadow: inset 3px 0 0 #dc2626;
}
}
&__num {
justify-content: flex-end;
text-align: right;
}
&__flag {
padding: 1px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
background: #fee2e2;
color: #b91c1c;
}
}
// ── 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;
}
&--danger {
margin-bottom: 12px;
background: #fef2f2;
border-color: #fecaca;
color: #b91c1c;
}
}
// ── 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;
}
}
}
// ── A DDS product's Accounting, as its own Accounting tab shows it ───────────────────────────────────
.ddr-acct {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 20px 20px;
&__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid $border;
border-radius: 10px;
background: $bg-white;
&--changed {
border-color: $warning-border;
background: $warning-bg;
}
@media (width <= 720px) {
flex-direction: column;
align-items: flex-start;
}
}
&__meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
&__kind {
flex: none;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
&--liability {
background: #dbeafe;
color: #1e40af;
}
&--expense {
background: #fef3c7;
color: #92400e;
}
}
&__label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: $text-dark;
}
&__value {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
&__gl {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: $brand;
&--old {
font-weight: 500;
color: $text-soft;
text-decoration: line-through;
}
&--none {
color: #b91c1c;
}
}
&__code {
padding: 1px 8px;
border: 1px solid $border;
border-radius: 6px;
background: $bg-soft;
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Consolas, monospace;
font-size: 12px;
font-weight: 600;
color: $text-mid;
}
&__arrow {
width: 18px;
height: 18px;
font-size: 18px;
color: $warning;
}
}
@@ -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,175 +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;
}
/** A commission slab request, laid out as the slab and the chart it joins rather than as a money movement. */
get isSlabReview(): boolean {
return this.entry?.entity === 'COMMISSIONSLAB' && this.fields.length > 0;
}
/** Every slab of the request's type, the requested one marked and any it overlaps flagged. */
get slabChart(): any[] {
return this.detail?.slabChart || [];
}
get slabOverlaps(): number {
return this.slabChart.filter((slab: any) => slab.overlaps).length;
}
/** What an agent earns at the start of the band: "₹50,001 → ₹1,000.02". */
get slabExample(): string {
const slab = this.slabChart.find((each: any) => each.requested);
if (!slab || slab.from === null || slab.from === undefined || slab.rate === null || slab.rate === undefined) {
return '';
}
const commission = (Number(slab.from) * Number(slab.rate)) / 100;
return `${this.rupees(slab.from)} → ${this.rupees(commission)}`;
}
private static readonly RUPEES = new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: 0,
maximumFractionDigits: 2
});
rupees(value: any): string {
return value === null || value === undefined || value === ''
? '—'
: DdsApprovalReviewComponent.RUPEES.format(Number(value));
}
percent(value: any): string {
return value === null || value === undefined || value === '' ? '—' : `${Number(value)}%`;
}
same(a: any, b: any): boolean {
return Number(a) === Number(b);
}
/** 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 || '';
}
/** The kind of ledger account a product's Accounting row names - customers' deposits are owed, the rest are costs. */
glKind(key: string): string {
return key === 'depositLiabilityGlId' ? 'Liability' : 'Expense';
}
/** "DDS CUSTOMER DEPOSITS (200601)" → "DDS CUSTOMER DEPOSITS"; a missing account reads "Not set". */
glName(value: string | null | undefined): string {
if (!value) {
return 'Not set';
}
const match = /^(.*) (([^()]+))$/.exec(value);
return match ? match[1] : value;
}
/** "DDS CUSTOMER DEPOSITS (200601)" → "200601". */
glCode(value: string | null | undefined): string {
const match = value ? /^(.*) (([^()]+))$/.exec(value) : null;
return match ? match[2] : '';
}
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',
Accounting: 'menu_book',
'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',
'Commission Slab': 'stacked_bar_chart'
};
/** 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. */
/**
* A calculation run is approved to record what has been earned in DDS's own tables; it makes no journal
* entry itself. The sides shown for it are what the matching Posting run will book, marked so they are
* not read as entries made on approval.
*/
get entriesProjected(): boolean {
return (this.detail?.lines || []).some((line: any) => line.projected);
}
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;
-31
View File
@@ -1,31 +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/.
*/
/**
* A DDS account's statuses, as a fixed deposit's run:
*
* ACTIVE → MATURED (maturity date reached, set nightly) → CLOSED (paid out at maturity)
* ACTIVE → PREMATURE_CLOSED (broken before maturity)
*
* Payout and premature closure close the account themselves; nobody closes one by hand.
*/
export const DDS_STATUS_LABELS: Record<string, string> = {
ACTIVE: 'Active',
MATURED: 'Matured',
CLOSED: 'Closed',
PREMATURE_CLOSED: 'Premature Closed'
};
/** Closed either way - at maturity or before it. */
export function isDdsClosed(status: string | null | undefined): boolean {
return status === 'CLOSED' || status === 'PREMATURE_CLOSED';
}
export function ddsStatusLabel(status: string | null | undefined): string {
return DDS_STATUS_LABELS[status ?? ''] ?? status ?? '';
}
@@ -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>
}
</tr>
</thead>
<tbody>
@@ -52,13 +37,9 @@
<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">
@@ -68,19 +49,16 @@
<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>
}
</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,39 +11,16 @@
<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>
@if (isClosed) {
<span class="pm-closed-badge">Closed — view only</span>
} @else if (termsLocked) {
<span
class="pm-locked-badge"
matTooltip="Customer, product, agent, branch, opening date and deposit terms (daily amount, deposit, days, months, interest/pay/reopen dates) cannot be changed on an active account. Contact details, nominees, lien and freeze can."
>
<i class="fas fa-lock"></i> Active — scheme terms locked
</span>
}
</div>
<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">
@@ -61,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>
@@ -206,7 +191,9 @@
<span class="pm-account-no">{{ accountNo }}</span>
<span class="pm-account-meta">
{{ accountSchemeName }} ·
<span [class.pm-account-live]="partyStatus === 'ACTIVE'">{{ statusLabel(partyStatus) }}</span>
<span [class.pm-account-live]="partyStatus === 'ACTIVE'">{{
partyStatus === 'ACTIVE' ? 'Active' : partyStatus
}}</span>
· {{ accountBalance | number: '1.2-2' }}
</span>
</div>
@@ -241,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">
@@ -274,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">
@@ -470,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>
@@ -572,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>
@@ -683,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>
@@ -834,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">
<div class="pm-section">
<div class="pm-section__header">
<i class="fas fa-exchange-alt"></i>
@@ -905,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>
<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">
@@ -939,80 +840,42 @@
</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-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>
</div>
<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) {
<div class="pm-nom-total" [class.pm-nom-total--bad]="nomineeShareError">
<div class="pm-nom-total__text">
<strong>{{ nomineeShareTotal }}%</strong> of 100% allocated
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeShareError">
{{ nomineeShareTotal }}% of 100% allocated
@if (nomineeShareError) {
<span class="pm-nom-total__error">— {{ nomineeShareError }}</span>
— {{ nomineeShareError }}
}
</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>
</span>
} @else if (splitBySequence && nominees.length > 0) {
<div class="pm-nom-total" [class.pm-nom-total--bad]="nomineeOrderError">
<div class="pm-nom-total__text">
<span class="pm-share-total" [class.pm-share-total-bad]="nomineeOrderError">
@if (nomineeOrderError) {
{{ nomineeOrderError }}
} @else {
Paid in the order numbered, 1 first
}
</div>
</div>
</span>
}
<button mat-stroked-button type="button" class="pm-nom-add" (click)="addNominee()">
<i class="fas fa-plus"></i> Add Nominee
</button>
}
</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>
<div class="pm-nom-card__grid">
<div class="pm-f">
<label class="pm-f-lbl">Nomination Date<b>*</b></label>
<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>
@@ -1031,26 +894,30 @@
<input matInput formControlName="mobileNo" mifosxRestrictInput="digits" />
</mat-form-field>
</div>
<div class="pm-f">
<label class="pm-f-lbl">Birth Date</label>
<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">
<label class="pm-f-lbl">Relation<b>*</b></label>
<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">
<mat-select required formControlName="relation">
@for (relation of relationChoices(nominee); track relation) {
<mat-option [value]="relation">{{ relation }}</mat-option>
}
</mat-select>
<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">
@@ -1061,16 +928,21 @@
</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>
<label class="pm-f-lbl">
Guar. Name
@if (isNomineeMinor(nominee)) {
<b>*</b>
}
</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>
<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">
<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
@@ -1085,26 +957,22 @@
</mat-form-field>
</div>
} @else {
<div class="pm-f">
<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>
}
<!-- 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>
<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>
@@ -1139,14 +1007,33 @@
<div class="pm-action-bar">
<button mat-raised-button type="button" (click)="exit()">Exit</button>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</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
<!--
Closing before the term is up. Kept here beside the ordinary close rather than on the party
list: it cannot be undone and it changes what the customer is owed, so it belongs on the
screen where the officer has the account open in front of them, not one click from a row.
Hidden once the account has matured - that one is paid out and closed on its own terms, and
the server refuses this on it.
-->
@if (partyId && !isClosed && canCloseEarly) {
<button
mat-stroked-button
type="button"
class="pm-early-btn"
matTooltip="Close this account before its term ends, settling the shortfall"
(click)="closeEarly()"
>
<i class="fas fa-times" style="margin-right: 6px"></i> Close Early
</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>
@if (partyId && !isClosed) {
<button
mat-stroked-button
type="button"
color="warn"
matTooltip="Balance must be zero before closing"
(click)="closeAccount()"
>
<i class="fas fa-lock" style="margin-right: 6px"></i> Close Account
</button>
}
<button
@@ -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;
@@ -197,22 +260,6 @@ $primary: #0f766e;
color: rgb(0 0 0 / 60%);
}
/* On an active account: the scheme terms are locked, the rest is editable. */
.pm-locked-badge {
margin-left: 12px;
padding: 3px 11px;
border-radius: 11px;
background: #fff7ed;
color: #9a3412;
font-size: 0.72rem;
font-weight: 600;
cursor: help;
i {
margin-right: 4px;
}
}
/* Says whose details a block of fields is for, so the screen explains itself. */
.pm-section__sub {
flex: 1 1 auto;
@@ -407,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;
@@ -461,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,9 +33,10 @@ 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 { ddsStatusLabel, isDdsClosed } from '../../dds-status';
import { searchClientsAnyCase } from '../client-search';
/** Custom Components */
import { PrematureCloseDialogComponent } from '../premature-close-dialog/premature-close-dialog.component';
/** Custom Directives */
import { RestrictInputDirective } from 'app/directives/restrict-input.directive';
@@ -58,8 +55,9 @@ import { RestrictInputDirective } from 'app/directives/restrict-input.directive'
* not - SubAgent was folded into Agent, so it is named for what it actually points at. Fields the legacy screen greyed out are either client-derived, calculated, or
* written by collections and the batch job - none of them are typed here.
*
* Closing an account - at nil balance, or early - is done from the DDS account page's actions menu,
* not from this form. A closed Party can no longer be edited or receive new Party Tran collections.
* "Close Account" (command=close) permanently closes the Party once its balance is zero - the
* backend rejects the command otherwise. A closed Party can no longer be edited or receive new
* Party Tran collections.
*
* savingsAccountNo optionally links this Party to an existing, non-closed real Savings Account of
* the same client (resolved by account number, since that's what an officer has to hand) -
@@ -123,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;
@@ -281,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[] = [];
@@ -295,35 +186,8 @@ export class CreatePartyComponent implements OnInit {
agentOptions: any[] = [];
private allAgents: any[] = [];
readonly statusLabel = ddsStatusLabel;
get isClosed(): boolean {
return isDdsClosed(this.partyStatus);
}
/**
* What an active account's scheme is built on - the customer and product, its branch, its opening
* its collecting agent and its deposit terms - is shown but cannot be changed, as the server refuses
* it too. Contact details, nominees, lien and freeze stay editable.
*/
static readonly LOCKED_WHEN_ACTIVE = [
'clientId',
'subType',
'agentId',
'officeId',
'openingDate',
'dailyDepositAmount',
'depositAmount',
'days',
'month',
'interestDate',
'payDate',
'reopenDate'
];
/** An account already running: its scheme terms are locked. */
get termsLocked(): boolean {
return !!this.partyId && !isDdsClosed(this.partyStatus);
return this.partyStatus === 'CLOSED';
}
/** The DDS Account Type master - what the legacy screen's Sub Type dropdown was fed from. */
@@ -462,6 +326,12 @@ export class CreatePartyComponent implements OnInit {
Validators.required
],
savingsAccountNo: [''],
lastAccount: [
{
value: '',
disabled: true
}
],
partyCode: [
{
value: '',
@@ -679,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));
@@ -721,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
@@ -738,21 +584,69 @@ 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 : '');
this.fillFromClient(typed, '');
}
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;
}
// 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.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 = [])
});
}
@@ -883,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) {
@@ -938,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) => {
@@ -953,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
@@ -1697,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,
@@ -1780,18 +1638,129 @@ export class CreatePartyComponent implements OnInit {
};
if (this.partyId) {
// An active account's locked terms are not sent at all, so an edit never even asks to change them.
if (this.termsLocked) {
for (const name of CreatePartyComponent.LOCKED_WHEN_ACTIVE) {
delete payload[name];
}
}
this.organizationService.updateParty(this.partyId, payload).subscribe(done);
} else {
this.organizationService.createParty(payload).subscribe(done);
}
}
closeAccount(): void {
if (!this.partyId) {
return;
}
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
this.organizationService
.closeParty(this.partyId, {
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
dateFormat,
locale
})
.subscribe(() => this.exit());
}
/**
* Whether this account can still be closed before its term is up.
*
* Only while the maturity date is ahead: once it has passed the account is paid out and closed on
* its own terms, and there is no shortfall to settle. The scheme's lock-in is deliberately not
* checked here - that period lives on the scheme, and a copy of the rule on this screen would drift
* from the one the server applies. An account inside its lock-in is offered the button and refused
* with the date it becomes closable, which tells the officer more than a missing button would.
*/
/**
* The client and agent as ids the API understands. Both fields are autocompletes, so each holds
* either the object picked from the list or the raw text typed into it - the dialog needs the
* client to look up which accounts can take the payout, and the agent's code only to name who is
* being charged.
*/
private get clientIdOfForm(): any {
const value = this.partyForm.get('clientId')?.value;
return value && typeof value === 'object' ? value.id : value;
}
private get agentCodeOfForm(): string {
const value = this.partyForm.get('agentId')?.value;
if (value && typeof value === 'object') {
return value.agentCode;
}
// The form holds the agent's id; the dialog names them by code.
const agent = (this.allAgents || []).find((option: any) => String(option.id) === String(value));
return agent?.agentCode ?? String(value ?? '');
}
get canCloseEarly(): boolean {
const maturity = this.partyForm.get('maturityDate')?.value;
if (!maturity) {
return false;
}
const matured = maturity instanceof Date ? maturity : new Date(maturity);
return !isNaN(matured.getTime()) && matured.getTime() > Date.now();
}
/**
* Closing early. The dialog asks the one thing the bank leaves open - whether the customer or the
* agent carries the cost - and shows what the scheme charges for each. Everything else, including
* the lock-in, is applied by the server.
*/
closeEarly(): void {
if (!this.partyId) {
return;
}
const dialogRef = this.dialog.open(PrematureCloseDialogComponent, {
data: {
partyCode: this.partyForm.get('partyCode')?.value,
partyName: this.partyForm.get('partyName')?.value,
subType: this.partyForm.get('subType')?.value,
interestRate: this.partyForm.get('interestRate')?.value,
maturityDate: this.partyForm.get('maturityDate')?.value,
currentBalance: this.partyForm.get('currentBalance')?.value,
openingDate: this.partyForm.get('openingDate')?.value,
clientId: this.clientIdOfForm,
agentCode: this.agentCodeOfForm
},
width: '560px',
autoFocus: false
});
dialogRef.afterClosed().subscribe((closeCommand: any) => {
if (!closeCommand) {
return;
}
const partyCode = this.partyForm.get('partyCode')?.value;
// Inside the lock-in it is a request with a reason, on its own permission, for a manager to pass.
const withinLockin = !!closeCommand.lockinOverrideReason;
// The date goes up the way every other date on this system does - formatted to the user's own
// setting, with that setting and their locale alongside so the server reads it back the same
// way. An ordinary close sends it like this too; two ways of closing an account should not
// disagree about something as plain as what day it is.
const dateFormat = this.settingsService.dateFormat;
const locale = this.settingsService.language.code;
const request = {
...closeCommand,
closeDate: this.dateUtils.formatDate(this.settingsService.businessDate || new Date(), dateFormat),
dateFormat,
locale
};
(withinLockin
? this.organizationService.prematureCloseOverrideParty(this.partyId as number, request)
: this.organizationService.prematureCloseParty(this.partyId as number, request)
).subscribe((response: any) => {
// Under maker-checker the command is only recorded, and nothing has closed yet.
const awaitingApproval = !!response?.commandId && response?.resourceId == null;
this.alertService.alert({
type: awaitingApproval ? 'Sent for Approval' : 'Party Closed',
message: awaitingApproval
? `Early closure of ${partyCode} is waiting for a manager's approval. The account stays open until then.`
: `${partyCode} is closed.`
});
this.exit();
});
// A refusal - inside the lock-in, held under lien, KYC outstanding - is shown by the platform's
// own error handler, which names the reason. Repeating it here would say the same thing twice.
});
}
// ------------------------------------------------------------------ helpers
/**
@@ -1881,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,
@@ -1961,10 +1931,6 @@ export class CreatePartyComponent implements OnInit {
// A closed party is a record to look at, not to change.
if (this.isClosed) {
this.partyForm.disable({ emitEvent: false });
} else if (this.termsLocked) {
for (const name of CreatePartyComponent.LOCKED_WHEN_ACTIVE) {
this.partyForm.get(name)?.disable({ emitEvent: false });
}
}
}
}
@@ -9,29 +9,21 @@
<div class="pl-root">
<div class="pl-header">
<div class="pl-header-left">
<div class="pl-header-ic"><fa-icon [icon]="selectedAgent ? 'user-tie' : 'users'"></fa-icon></div>
<div class="pl-header-ic"><fa-icon icon="users"></fa-icon></div>
<div class="pl-header-text">
<h2>{{ pageTitle }}</h2>
<p>{{ pageSubtitle }}</p>
<h2>Party Master</h2>
<p>The DDS accounts on file — each one a customer collected from daily.</p>
</div>
</div>
<div class="pl-header-right">
@if (selectedAgent) {
<div class="pl-count-chip" matTooltip="Accounts this agent collects for">
<div class="pl-count-chip" matTooltip="Total parties">
<fa-icon icon="users"></fa-icon>
<strong>{{ selectedAgent.accounts }}</strong>
<span>{{ selectedAgent.accounts === 1 ? 'account' : 'accounts' }}</span>
<strong>{{ totalCount }}</strong>
<span>{{ totalCount === 1 ? 'party' : 'parties' }}</span>
</div>
} @else {
<div class="pl-count-chip" matTooltip="Agents with DDS accounts">
<fa-icon icon="user-tie"></fa-icon>
<strong>{{ agentGroups.length }}</strong>
<span>{{ agentGroups.length === 1 ? 'agent' : 'agents' }}</span>
</div>
}
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Open DDS Account</span>
<span>Create Party</span>
</button>
</div>
</div>
@@ -61,432 +53,57 @@
</a>
}
<!--
The page level shows only the counts - agents and accounts - so it reads at a glance. The figures
are worked out agent by agent, and an agent's own page shows them in full.
-->
@if (loaded && totalCount > 0) {
@if (!selectedAgent) {
<div class="pm-dash pm-dash--six">
<div class="pm-kpi pm-kpi--slate">
<span class="pm-kpi__ic"><fa-icon icon="user-tie"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ agentGroups.length }}</b>
<small>Total agents</small>
</span>
</div>
<div class="pm-kpi pm-kpi--blue">
<span class="pm-kpi__ic"><fa-icon icon="users"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ book.accounts }}</b>
<small>Total DDS accounts</small>
</span>
</div>
<div class="pm-kpi pm-kpi--green">
<span class="pm-kpi__ic"><fa-icon icon="check-circle"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ book.open }}</b>
<small>Active accounts</small>
</span>
</div>
<div class="pm-kpi pm-kpi--amber">
<span class="pm-kpi__ic"><fa-icon icon="calendar-check"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ book.matured }}</b>
<small>Matured accounts</small>
<em>{{ book.maturedBalance | number: '1.2-2' }} to pay out</em>
</span>
</div>
<div class="pm-kpi pm-kpi--grey">
<span class="pm-kpi__ic"><fa-icon icon="lock"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ book.closed }}</b>
<small>Closed accounts</small>
<em>{{ book.prematureClosed }} premature · {{ book.closed - book.prematureClosed }} at maturity</em>
</span>
</div>
<div class="pm-kpi pm-kpi--teal">
<span class="pm-kpi__ic"><fa-icon icon="wallet"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ book.balance | number: '1.2-2' }}</b>
<small>Total deposits held</small>
</span>
<div class="pl-kpis">
<div class="pl-kpi pl-kpi--blue">
<div class="pl-kpi-ic"><fa-icon icon="users"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ activeCount | number }}</div>
<div class="pl-kpi-l">Open Accounts</div>
</div>
</div>
} @else if (agentLoading) {
<!-- The agent's accounts are on their way; their figures are worked out from them. -->
<div class="pm-loading">
<span class="pm-loading__spin"></span>
Loading {{ selectedAgent.agentName }}'s accounts…
<div
class="pl-kpi pl-kpi--purple"
matTooltip="What the agents should collect between them on a full collection day"
>
<div class="pl-kpi-ic"><fa-icon icon="calendar-day"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalDailyDue | number: '1.2-2' }}</div>
<div class="pl-kpi-l">Daily Collection Due</div>
</div>
} @else {
<!--
One agent in full, in three plain groups: their accounts, the money, and the collection. Every
figure is a View: clicking it lists, below, the accounts that make it up.
-->
<h4 class="pm-group">Accounts</h4>
<div class="pm-dash">
<button
type="button"
class="pm-kpi pm-kpi--blue pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'all1'"
(click)="showView('all', 'all1')"
>
<span class="pm-kpi__ic"><fa-icon icon="users"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.accounts }}</b>
<small>Total accounts</small>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
<button
type="button"
class="pm-kpi pm-kpi--green pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'active2'"
(click)="showView('active', 'active2')"
>
<span class="pm-kpi__ic"><fa-icon icon="check-circle"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.open }}</b>
<small>Active</small>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
<button
type="button"
class="pm-kpi pm-kpi--grey pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'closed3'"
(click)="showView('closed', 'closed3')"
>
<span class="pm-kpi__ic"><fa-icon icon="lock"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.closed }}</b>
<small>Closed</small>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
<button
type="button"
class="pm-kpi pm-kpi--slate pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'nominee4'"
(click)="showView('nominee', 'nominee4')"
>
<span class="pm-kpi__ic"><fa-icon icon="layer-group"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.withNominee }}</b>
<small>With nominee</small>
<em>{{ figures.minors }} minor {{ figures.minors === 1 ? 'account' : 'accounts' }}</em>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
</div>
<h4 class="pm-group">Maturity</h4>
<div class="pm-dash">
<button
type="button"
class="pm-kpi pm-kpi--amber pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'matured21'"
(click)="showView('matured', 'matured21')"
>
<span class="pm-kpi__ic"><fa-icon icon="calendar-check"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.matured }}</b>
<small>Matured</small>
<em>{{ figures.maturedBalance | number: '1.2-2' }} awaiting payout</em>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
<button
type="button"
class="pm-kpi pm-kpi--indigo pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'soon22'"
(click)="showView('soon', 'soon22')"
>
<span class="pm-kpi__ic"><fa-icon icon="hourglass-half"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.maturingSoon }}</b>
<small>Maturing in 30 days</small>
<em>nearest maturity first</em>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
<button
type="button"
class="pm-kpi pm-kpi--green pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'closedMaturity23'"
(click)="showView('closedMaturity', 'closedMaturity23')"
>
<span class="pm-kpi__ic"><fa-icon icon="wallet"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.closedAtMaturity }}</b>
<small>Closed at maturity</small>
<em>paid out and closed</em>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
<button
type="button"
class="pm-kpi pm-kpi--red pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'premature24'"
(click)="showView('premature', 'premature24')"
>
<span class="pm-kpi__ic"><fa-icon icon="times-circle"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.prematureClosed }}</b>
<small>Premature closed</small>
<em>closed before maturity</em>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
<div class="pl-kpi pl-kpi--teal" matTooltip="Every open account's running balance added up">
<div class="pl-kpi-ic"><fa-icon icon="wallet"></fa-icon></div>
<div>
<div class="pl-kpi-n">{{ totalCurrentBalance | number: '1.2-2' }}</div>
<div class="pl-kpi-l">Total Deposits Held</div>
</div>
<h4 class="pm-group">Money</h4>
<div class="pm-dash">
<button
type="button"
class="pm-kpi pm-kpi--teal pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'balance5'"
(click)="showView('balance', 'balance5')"
>
<span class="pm-kpi__ic"><fa-icon icon="wallet"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.balance | number: '1.2-2' }}</b>
<small>Balance held</small>
<em>what the accounts hold today</em>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
<button
type="button"
class="pm-kpi pm-kpi--purple pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'active6'"
(click)="showView('active', 'active6')"
>
<span class="pm-kpi__ic"><fa-icon icon="calendar-day"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.dailyDue | number: '1.2-2' }}</b>
<small>Daily collection</small>
<em>due each day from active accounts</em>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
<button
type="button"
class="pm-kpi pm-kpi--amber pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'interest7'"
(click)="showView('interest', 'interest7')"
>
<span class="pm-kpi__ic"><fa-icon icon="percentage"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.interestAccrued | number: '1.2-2' }}</b>
<small>Interest to post</small>
<em>calculated, not in the balance yet</em>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
<button
type="button"
class="pm-kpi pm-kpi--indigo pm-kpi--click"
[class.pm-kpi--on]="cardKey === 'maturing8'"
(click)="showView('maturing', 'maturing8')"
>
<span class="pm-kpi__ic"><fa-icon icon="calendar-check"></fa-icon></span>
<span class="pm-kpi__body">
<b>{{ figures.maturityValue | number: '1.2-2' }}</b>
<small>Maturity value</small>
<em>{{ figures.maturingSoon }} maturing in 30 days · {{ figures.matured }} matured</em>
</span>
<span class="pm-kpi__view">View <fa-icon icon="arrow-right"></fa-icon></span>
</button>
</div>
<h4 class="pm-group">Collection</h4>
<div class="pm-collect">
<div class="pm-collect__nums">
<button
type="button"
class="pm-collect__stat"
[class.pm-collect__stat--on]="cardKey === 'active9'"
(click)="showView('active', 'active9')"
>
<small>Collected so far</small>
<b class="pm-collect__in">{{ figures.collected | number: '1.2-2' }}</b>
<span class="pm-collect__view">View</span>
</button>
<button
type="button"
class="pm-collect__stat"
[class.pm-collect__stat--on]="cardKey === 'active10'"
(click)="showView('active', 'active10')"
>
<small>Due till today</small>
<b>{{ figures.expected | number: '1.2-2' }}</b>
<span class="pm-collect__view">View</span>
</button>
<button
type="button"
class="pm-collect__stat"
[class.pm-collect__stat--on]="cardKey === 'behind11'"
(click)="showView('behind', 'behind11')"
>
<small>Pending</small>
<b [class.pm-collect__due]="figures.arrearsAmount > 0">{{ figures.arrearsAmount | number: '1.2-2' }}</b>
<span class="pm-collect__view">View</span>
</button>
<button
type="button"
class="pm-collect__stat"
[class.pm-collect__stat--on]="cardKey === 'behind12'"
(click)="showView('behind', 'behind12')"
>
<small>Accounts behind</small>
<b [class.pm-collect__due]="figures.inArrears > 0">{{ figures.inArrears }}</b>
<span class="pm-collect__view">View</span>
</button>
<button
type="button"
class="pm-collect__stat"
[class.pm-collect__stat--on]="cardKey === 'ahead13'"
(click)="showView('ahead', 'ahead13')"
>
<small>Paid ahead</small>
<b>{{ figures.inAdvance }}</b>
<span class="pm-collect__view">View</span>
</button>
</div>
<div class="pm-collect__bar"><span [style.width.%]="collectionRate"></span></div>
<div class="pm-collect__note">{{ collectionRate }}% of what was due till today has come in.</div>
</div>
<div class="pm-products">
<span class="pm-products__label">By product</span>
@for (product of figures.byProduct; track product.name) {
<span class="pm-products__chip"
>{{ product.name }} <b>{{ product.count }}</b></span
>
}
</div>
}
}
<div class="pl-card">
<div class="pl-toolbar pm-toolbar">
@if (selectedAgent) {
<!-- Where the officer is: back to every agent, and the agent whose accounts are open. -->
<div class="pm-crumb">
<button type="button" class="pm-crumb__back" (click)="backToAgents()" matTooltip="Back to all agents">
<fa-icon icon="arrow-left"></fa-icon>
</button>
<button type="button" class="pm-crumb__link" (click)="backToAgents()">All agents</button>
<fa-icon icon="chevron-right" class="pm-crumb__sep"></fa-icon>
<span class="pm-crumb__here">
<strong>{{ selectedAgent.agentName }}</strong>
<span class="pm-crumb__code">{{ selectedAgent.agentCode }}</span>
</span>
</div>
}
<div class="pl-toolbar">
<div class="pl-search">
<fa-icon icon="search" class="pl-search-ic"></fa-icon>
<!-- Tracked so switching between agents and accounts starts the search over. -->
@if (selectedAgent) {
<input
type="text"
(input)="applyFilter($any($event.target).value)"
placeholder="Search {{ selectedAgent.agentName }}'s accounts by code or name…"
placeholder="Search by code, name or agent…"
/>
} @else {
<input
type="text"
[value]="agentSearch"
(input)="applyFilter($any($event.target).value)"
placeholder="Search agents by code, name or branch…"
/>
}
</div>
</div>
@if (totalCount === 0) {
<div class="pl-empty">
<div class="pl-empty-ic"><fa-icon icon="users"></fa-icon></div>
<h3>No DDS accounts yet</h3>
<h3>No parties yet</h3>
<p>Open one so an agent can start collecting daily deposits against it.</p>
<button class="pl-create-btn" type="button" [routerLink]="['create']">
<fa-icon icon="plus"></fa-icon>
<span>Open DDS Account</span>
<span>Create Party</span>
</button>
</div>
} @else if (!selectedAgent) {
<!--
First level: the agents. Each card is what that agent collects for - accounts, the day's
collection due and the balance held - and opens their accounts on a click.
-->
<div class="pm-agents">
@for (group of pagedAgents; track group.agentCode) {
<button type="button" class="pm-agent" (click)="openAgent(group)">
<span class="pm-agent__avatar"><fa-icon icon="user-tie"></fa-icon></span>
<span class="pm-agent__who">
<strong>{{ group.agentName }}</strong>
<span class="pm-agent__code"
>{{ group.agentCode }}{{ group.officeName ? ' · ' + group.officeName : '' }}</span
>
</span>
<span class="pm-agent__stat">
<b>{{ group.open }}</b>
<small>open{{ group.closed ? ' · ' + group.closed + ' closed' : '' }}</small>
</span>
<span class="pm-agent__stat">
<b>{{ group.dailyDue | number: '1.2-2' }}</b>
<small>daily due</small>
</span>
<span class="pm-agent__stat">
<b>{{ group.collected | number: '1.2-2' }}</b>
<small>collected</small>
</span>
<span class="pm-agent__stat">
<b>{{ group.balance | number: '1.2-2' }}</b>
<small>balance held</small>
</span>
<span class="pm-agent__flag" [class.pm-agent__flag--bad]="group.inArrears > 0">
@if (group.inArrears > 0) {
{{ group.inArrears }} behind
} @else {
On track
}
</span>
<span class="pm-agent__go"><fa-icon icon="chevron-right"></fa-icon></span>
</button>
} @empty {
<div class="pl-no-match">
<fa-icon icon="search"></fa-icon>
<span>No agents match your search.</span>
</div>
}
</div>
@if (visibleAgents.length > 0) {
<mat-paginator
[length]="visibleAgents.length"
[pageIndex]="agentPageIndex"
[pageSize]="agentPageSize"
[pageSizeOptions]="[5, 10, 25, 50]"
(page)="onAgentPage($event)"
showFirstLastButtons
></mat-paginator>
}
} @else {
@if (cardView !== 'all') {
<div class="pm-viewing">
<fa-icon icon="filter"></fa-icon>
<span
>Showing <b>{{ viewLabel }}</b> · {{ dataSource.data.length }}
{{ dataSource.data.length === 1 ? 'account' : 'accounts' }}</span
>
<button type="button" class="pm-viewing__clear" (click)="showView('all')">
Show all <fa-icon icon="times"></fa-icon>
</button>
</div>
}
<div class="pl-table-wrap" #accountsTable>
<div class="pl-table-wrap">
<table mat-table [dataSource]="dataSource" matSort class="pl-table">
<ng-container matColumnDef="partyCode">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Party Code</th>
@@ -558,22 +175,11 @@
</td>
</ng-container>
<ng-container matColumnDef="schedule">
<th mat-header-cell *matHeaderCellDef>Schedule</th>
<td mat-cell *matCellDef="let p">
<span class="pm-sched" [ngClass]="'pm-sched--' + scheduleOf(p).tone">{{ scheduleOf(p).label }}</span>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let p">
<span
class="pm-pill"
[class.pm-pill--off]="isClosed(p)"
[class.pm-pill--matured]="p.status === 'MATURED'"
>
{{ statusLabel(p.status) }}
<span class="pm-pill" [class.pm-pill--off]="p.status === 'CLOSED'">
{{ p.status === 'CLOSED' ? 'Closed' : 'Active' }}
</span>
</td>
</ng-container>
@@ -598,29 +204,16 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let p" class="num pm-actions">
<!-- The account itself - balance, transactions, passbook - without opening it for editing. -->
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="['/accounts/dds-accounts', p.id]"
[queryParams]="{ agent: selectedAgent?.agentId }"
matTooltip="View this account, its transactions and passbook"
>
<fa-icon icon="eye"></fa-icon>
<span>View</span>
</button>
@if (!isClosed(p)) {
<td mat-cell *matCellDef="let p" class="num">
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
matTooltip="Edit this account"
[matTooltip]="p.status === 'CLOSED' ? 'View this closed party' : 'Edit this party'"
>
<fa-icon icon="edit"></fa-icon>
<span>Edit</span>
<fa-icon [icon]="p.status === 'CLOSED' ? 'eye' : 'edit'"></fa-icon>
<span>{{ p.status === 'CLOSED' ? 'View' : 'Edit' }}</span>
</button>
}
</td>
</ng-container>
@@ -629,23 +222,18 @@
mat-row
*matRowDef="let row; columns: displayedColumns"
class="pl-row"
[class.pm-row--closed]="isClosed(row)"
[class.pm-row--closed]="row.status === 'CLOSED'"
></tr>
</table>
@if (dataSource.filteredData.length === 0 && totalCount > 0) {
@if (filteredCount === 0 && totalCount > 0) {
<div class="pl-no-match">
<fa-icon icon="search"></fa-icon>
<span>No accounts of {{ selectedAgent.agentName }} match your search.</span>
<span>No parties match your filter.</span>
</div>
}
<mat-paginator
#partyPaginator
[pageSizeOptions]="[10, 25, 50, 100]"
[pageSize]="10"
showFirstLastButtons
></mat-paginator>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" [pageSize]="10" showFirstLastButtons></mat-paginator>
</div>
}
</div>
@@ -38,11 +38,6 @@
color: rgb(0 0 0 / 52%);
}
.pm-pill--matured {
background: #fef3c7;
color: #b45309;
}
/* The customer's real deposit account number, in the list. */
.pm-list-account {
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', monospace;
@@ -136,626 +131,3 @@
.pl-muted {
color: rgb(0 0 0 / 40%);
}
/* ── Agent first: the agents, then one agent's accounts ───────────────────── */
.pm-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
}
.pm-crumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #475569;
&__back {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
color: #0f172a;
cursor: pointer;
&:hover {
background: var(--pl-accent-l);
border-color: var(--pl-accent);
color: var(--pl-accent);
}
}
&__link {
border: none;
background: none;
padding: 0;
color: var(--pl-accent);
font-weight: 600;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
&__sep {
font-size: 11px;
color: #94a3b8;
}
&__here {
display: inline-flex;
align-items: baseline;
gap: 8px;
color: #0f172a;
}
&__code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
color: #64748b;
}
}
.pm-agents {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 20px 20px;
}
.pm-agent {
display: grid;
grid-template-columns: auto minmax(170px, 1.6fr) repeat(4, minmax(100px, 1fr)) auto auto;
align-items: center;
gap: 14px;
width: 100%;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
text-align: left;
cursor: pointer;
transition:
border-color 0.15s,
box-shadow 0.15s,
transform 0.15s;
&:hover {
border-color: var(--pl-accent);
box-shadow: 0 4px 14px rgb(15 23 42 / 8%);
transform: translateY(-1px);
.pm-agent__go {
background: var(--pl-accent);
color: #fff;
}
}
&__avatar {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--pl-accent-l);
color: var(--pl-accent);
font-size: 15px;
}
&__who {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
strong {
font-size: 15px;
color: #0f172a;
overflow-wrap: anywhere;
}
}
&__code {
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12px;
color: #64748b;
}
&__stat {
display: flex;
flex-direction: column;
gap: 2px;
b {
font-size: 15px;
color: #0f172a;
}
small {
font-size: 11.5px;
color: #64748b;
}
}
&__go {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: #f1f5f9;
color: #475569;
transition:
background 0.15s,
color 0.15s;
}
}
@media (width <= 760px) {
.pm-agent {
grid-template-columns: auto 1fr auto;
.pm-agent__stat {
grid-column: 2 / 3;
}
}
}
/* ── Dashboard: compact cards; on an agent's page each one opens its accounts ── */
.pm-dash {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 8px;
&--five {
grid-template-columns: repeat(5, minmax(0, 1fr));
margin-bottom: 14px;
}
&--six {
grid-template-columns: repeat(6, minmax(0, 1fr));
margin-bottom: 14px;
}
}
@media (width <= 1100px) {
.pm-dash {
grid-template-columns: repeat(2, minmax(0, 1fr));
&--five,
&--six {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
}
@media (width <= 560px) {
.pm-dash,
.pm-dash--five,
.pm-dash--six {
grid-template-columns: minmax(0, 1fr);
}
}
.pm-kpi {
--k: #2563eb;
--k-l: #eff6ff;
position: relative;
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-left: 3px solid var(--k);
border-radius: 10px;
background: #fff;
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
&__ic {
display: grid;
flex-shrink: 0;
place-items: center;
width: 32px;
height: 32px;
border-radius: 9px;
background: var(--k-l);
color: var(--k);
font-size: 14px;
}
&__body {
display: flex;
flex: 1;
flex-direction: column;
gap: 1px;
min-width: 0;
b {
font-size: 17px;
line-height: 1.2;
color: #0f172a;
}
small {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #475569;
}
em {
overflow: hidden;
font-size: 11px;
font-style: normal;
color: #64748b;
white-space: nowrap;
text-overflow: ellipsis;
}
}
/* View sits in the top-right corner, clear of the figure and its note */
&__view {
position: absolute;
top: 8px;
right: 10px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
background: var(--k-l);
font-size: 11px;
font-weight: 700;
color: var(--k);
opacity: 0.8;
transition:
opacity 0.15s,
background 0.15s,
color 0.15s;
}
&--click {
width: 100%;
padding-right: 64px;
font: inherit;
text-align: left;
cursor: pointer;
transition:
box-shadow 0.15s,
transform 0.15s;
&:hover {
box-shadow: 0 6px 16px rgb(15 23 42 / 10%);
transform: translateY(-1px);
.pm-kpi__view {
opacity: 1;
}
}
}
/* The card clicked: its own colour, nothing borrowed */
&--on {
background: var(--k-l);
box-shadow: 0 0 0 2px var(--k);
.pm-kpi__view {
background: var(--k);
color: #fff;
opacity: 1;
}
}
&--blue {
--k: #2563eb;
--k-l: #eff6ff;
}
&--purple {
--k: #7c3aed;
--k-l: #f5f3ff;
}
&--teal {
--k: #0f766e;
--k-l: #f0fdfa;
}
&--green {
--k: #15803d;
--k-l: #f0fdf4;
}
&--red {
--k: #dc2626;
--k-l: #fef2f2;
}
&--grey {
--k: #64748b;
--k-l: #f1f5f9;
}
&--amber {
--k: #b45309;
--k-l: #fffbeb;
}
&--indigo {
--k: #4338ca;
--k-l: #eef2ff;
}
&--slate {
--k: #334155;
--k-l: #f1f5f9;
}
}
.pm-group {
margin: 2px 0 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #64748b;
}
.pm-collect {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
box-shadow: 0 1px 3px rgb(15 23 42 / 6%);
&__nums {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
small {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
}
b {
font-size: 16px;
color: #0f172a;
}
}
/* One figure, which lists its accounts on a click */
&__stat {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 8px;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
border-color: #e2e8f0;
background: #f8fafc;
}
/* Picked: the bank's green, the colour of the collection bar */
&--on {
border-color: #1f7a5c;
background: #e6f4f0;
box-shadow: 0 0 0 1px #1f7a5c;
}
}
&__view {
font-size: 11px;
font-weight: 700;
color: #1f7a5c;
}
&__in {
color: #15803d !important;
}
&__due {
color: #b91c1c !important;
}
&__bar {
height: 6px;
margin-top: 8px;
overflow: hidden;
border-radius: 999px;
background: #e2e8f0;
> span {
display: block;
height: 100%;
border-radius: 999px;
background: #15803d;
}
}
&__note {
margin-top: 4px;
font-size: 11.5px;
color: #475569;
}
}
@media (width <= 760px) {
.pm-collect__nums {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.pm-products {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
&__label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
}
&__chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
font-size: 12px;
color: #334155;
b {
padding: 0 7px;
border-radius: 999px;
background: var(--pl-accent-l);
color: var(--pl-accent);
}
}
}
/* An agent card's on-track / behind flag */
.pm-agent__flag {
padding: 3px 10px;
border-radius: 999px;
background: #f0fdf4;
color: #15803d;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
&--bad {
background: #fef2f2;
color: #b91c1c;
}
}
/* An account's standing against its daily schedule */
.pm-sched {
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
&--ok {
background: #f0fdf4;
color: #15803d;
}
&--good {
background: #eff6ff;
color: #1d4ed8;
}
&--bad {
background: #fef2f2;
color: #b91c1c;
}
&--off {
background: #f1f5f9;
color: #64748b;
}
}
/* While an agent's accounts are fetched */
.pm-loading {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
padding: 14px 16px;
border: 1px dashed #e2e8f0;
border-radius: 10px;
background: #fff;
color: #475569;
font-size: 13.5px;
&__spin {
width: 18px;
height: 18px;
border: 2px solid var(--pl-accent-l);
border-top-color: var(--pl-accent);
border-radius: 50%;
animation: pm-spin 0.8s linear infinite;
}
}
@keyframes pm-spin {
to {
transform: rotate(360deg);
}
}
/* Which accounts the table is showing, after a card was clicked */
.pm-viewing {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin: 0 20px 10px;
padding: 8px 12px;
border: 1px solid #b7ddd0;
border-radius: 10px;
background: #e6f4f0;
font-size: 13px;
color: #334155;
fa-icon {
color: #1f7a5c;
}
&__clear {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
padding: 4px 12px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
font: inherit;
font-size: 12px;
font-weight: 600;
color: #0f172a;
cursor: pointer;
&:hover {
border-color: #1f7a5c;
color: #1f7a5c;
}
}
}
/* View and Edit side by side, never wrapped onto two lines */
.pm-actions {
white-space: nowrap;
}
@@ -7,8 +7,8 @@
*/
/** Angular Imports */
import { Component, ElementRef, OnInit, ViewChild, inject } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import {
MatTable,
@@ -28,77 +28,19 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
import { ddsStatusLabel, isDdsClosed } from '../dds-status';
/** Custom Components */
import { NomineeListDialogComponent } from './nominee-list-dialog/nominee-list-dialog.component';
/**
* What a set of DDS accounts adds up to - the whole book, or one agent's share of it. Worked out the
* same way for both, so an agent's figures are the book's figures narrowed to that agent.
*/
interface BookFigures {
accounts: number;
open: number;
closed: number;
dailyDue: number;
balance: number;
collected: number;
expected: number;
/** Open accounts behind on their instalments, and what is owed on them. */
inArrears: number;
arrearsAmount: number;
/** Open accounts paid ahead of schedule. */
inAdvance: number;
/** Interest calculated but not yet posted into the balances. */
interestAccrued: number;
maturityValue: number;
/** Open accounts whose term ends within the next 30 days, and those already past it. */
maturingSoon: number;
matured: number;
/** What the matured, still-open accounts hold - what their payouts will hand over. */
maturedBalance: number;
/** Closed accounts, by how they closed: paid out at maturity, or broken before it. */
closedAtMaturity: number;
prematureClosed: number;
minors: number;
withNominee: number;
/** Accounts by DDS product, largest first. */
byProduct: { name: string; count: number }[];
}
/**
* One agent's line of the DDS book, as the server adds it up (/dds/parties/summary): the agent cards
* and the page's counts are built from these alone, without any account being sent.
*/
interface AgentGroup {
agentId: number | null;
agentCode: string;
agentName: string;
officeName: string;
accounts: number;
open: number;
closed: number;
matured: number;
maturedBalance: number;
prematureClosed: number;
dailyDue: number;
balance: number;
collected: number;
inArrears: number;
}
/**
* DDS accounts, agent first.
* Party Master list.
*
* The first level is the agents - the book's counts, then each agent with what they collect for - read
* as one summary line per agent, so it opens as fast with a hundred thousand accounts as with ten.
* Opening an agent fetches that agent's accounts only, and works out their full figures from them.
* A branch runs its DDS book agent by agent, so that is the order an officer looks for an account in.
* The DDS accounts on file - each one a customer's daily deposit account, opened under a scheme
* (Sub Type) and assigned to the agent who collects for it. Follows the same list -> create/edit
* shape as Agent Master and the product lists, and shares their stylesheet.
*/
@Component({
selector: 'mifosx-party-master',
@@ -124,110 +66,17 @@ interface AgentGroup {
]
})
export class PartyMasterComponent implements OnInit {
readonly isClosed = (p: any): boolean => isDdsClosed(p?.status);
readonly statusLabel = ddsStatusLabel;
private organizationService = inject(OrganizationService);
private dialog = inject(MatDialog);
private route = inject(ActivatedRoute);
private router = inject(Router);
private static readonly DAY_MS = 24 * 60 * 60 * 1000;
/** Every agent with accounts, by agent code; the list's first level. */
agentGroups: AgentGroup[] = [];
/** The agent whose accounts are open below, or null while the agents are listed. */
selectedAgent: AgentGroup | null = null;
agentSearch = '';
agentPageIndex = 0;
agentPageSize = 10;
/** The whole book's counts, added up from the agents' summary lines. */
book = { accounts: 0, open: 0, closed: 0, matured: 0, maturedBalance: 0, prematureClosed: 0, balance: 0 };
/** The open agent's accounts, all of them; the table shows those the chosen card picks out. */
private agentParties: any[] = [];
/** Which accounts the table is showing; 'all' until a card is clicked. */
cardView = 'all';
/** The card that was clicked - two cards can list the same accounts, and only the one clicked lights up. */
cardKey = 'all';
private static readonly VIEW_LABELS: Record<string, string> = {
all: 'All accounts',
active: 'Active accounts',
closed: 'Closed accounts',
nominee: 'Accounts with a nominee',
balance: 'All accounts, largest balance first',
interest: 'Accounts with interest to post',
maturing: 'Active accounts, nearest maturity first',
matured: 'Matured accounts awaiting payout',
soon: 'Accounts maturing in the next 30 days',
closedMaturity: 'Accounts closed at maturity',
premature: 'Accounts closed prematurely',
behind: 'Accounts behind schedule',
ahead: 'Accounts paid ahead'
};
get viewLabel(): string {
return PartyMasterComponent.VIEW_LABELS[this.cardView] ?? 'All accounts';
}
/**
* The accounts behind a figure: clicking a card lists, in the table below, exactly the accounts that
* make up its number - the same tests figuresOf counts them by - and scrolls the table into view.
*/
showView(view: string, key: string = view): void {
this.cardView = view;
this.cardKey = key;
const open = (p: any) => !isDdsClosed(p.status);
const today = new Date();
today.setHours(0, 0, 0, 0);
const soon = today.getTime() + 30 * PartyMasterComponent.DAY_MS;
const maturesAt = (p: any) => PartyMasterComponent.dateOf(p.maturityDate)?.getTime() ?? Number.MAX_SAFE_INTEGER;
const tests: Record<string, (p: any) => boolean> = {
all: () => true,
balance: () => true,
active: open,
closed: (p) => !open(p),
nominee: (p) => open(p) && (p.nominees || []).length > 0,
interest: (p) => open(p) && Number(p.interestCalc || 0) > 0,
maturing: open,
matured: (p) => PartyMasterComponent.isMatured(p, today.getTime()),
soon: (p) => open(p) && maturesAt(p) > today.getTime() && maturesAt(p) <= soon,
closedMaturity: (p) => p.status === 'CLOSED',
premature: (p) => p.status === 'PREMATURE_CLOSED',
behind: (p) => open(p) && Number(p.arrearsDays || 0) > 0,
ahead: (p) => open(p) && Number(p.arrearsDays || 0) === 0 && Number(p.advanceDays || 0) > 0
};
let rows = this.agentParties.filter(tests[view] ?? tests['all']);
if (view === 'balance') {
rows = [...rows].sort((a, b) => Number(b.currentBalance || 0) - Number(a.currentBalance || 0));
} else if (view === 'maturing' || view === 'matured' || view === 'soon') {
rows = [...rows].sort((a, b) => maturesAt(a) - maturesAt(b));
}
this.dataSource.data = rows;
this.paginator?.firstPage();
if (view !== 'all') {
setTimeout(() => this.accountsTable?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' }));
}
}
@ViewChild('accountsTable') accountsTable?: ElementRef<HTMLElement>;
/** The open agent's full figures, worked out from their accounts once those have come in. */
agentFigures: BookFigures | null = null;
agentLoading = false;
// The agent is known once one is opened, so the accounts table leaves out its Collected By column.
displayedColumns: string[] = [
'partyCode',
'partyName',
'subType',
'agentName',
'savingsAccountNo',
'dailyDepositAmount',
'currentBalance',
'schedule',
'nominees',
'status',
'actions'
@@ -265,15 +114,15 @@ export class PartyMasterComponent implements OnInit {
dueForClosurePayable = 0;
dataSource: MatTableDataSource<any> = new MatTableDataSource<any>([]);
loaded = false;
parties: any[] = [];
filteredCount = 0;
paginator: MatPaginator;
sort: MatSort;
// The table sits inside an @if, so these only exist once an agent is open. Read statically they
// The table sits inside an @if, so these only exist once the list has loaded. Read statically they
// were never there, the table was never paged, and the paginator read "0 of 0" over a full list.
// Picked by name: the agents have a paginator of their own, which must not page this table.
@ViewChild('partyPaginator') set matPaginator(paginator: MatPaginator) {
@ViewChild(MatPaginator) set matPaginator(paginator: MatPaginator) {
this.paginator = paginator;
if (paginator) {
this.dataSource.paginator = paginator;
@@ -288,75 +137,33 @@ export class PartyMasterComponent implements OnInit {
}
get totalCount(): number {
return this.book.accounts;
return this.parties.length;
}
/** The open agent's full figures; the agent page shows them once the accounts are in. */
get figures(): BookFigures {
return this.agentFigures ?? PartyMasterComponent.NO_FIGURES;
get activeCount(): number {
return this.parties.filter((party) => party.status !== 'CLOSED').length;
}
private static readonly NO_FIGURES: BookFigures = PartyMasterComponent.figuresOf([]);
/** The page's name for what is on screen. */
get pageTitle(): string {
return this.selectedAgent ? `${this.selectedAgent.agentName} — DDS Accounts` : 'DDS Agents & Accounts';
/** What the whole book is worth today - every open account's running balance added up. */
get totalCurrentBalance(): number {
return this.parties.reduce((sum, party) => sum + Number(party.currentBalance || 0), 0);
}
get pageSubtitle(): string {
if (this.selectedAgent) {
const a = this.selectedAgent;
return `Agent ${a.agentCode}${a.officeName ? ' · ' + a.officeName : ''} · ${a.open} open ${
a.open === 1 ? 'account' : 'accounts'
} collected daily.`;
}
return 'Every agent and the daily-deposit accounts they collect for. Open an agent to see their accounts.';
}
/** Collected so far as a share of what was due by today, for the progress bar. */
get collectionRate(): number {
const f = this.figures;
return f.expected > 0 ? Math.min(100, Math.round((f.collected / f.expected) * 100)) : 100;
/** What the agents should be collecting between them on a full collection day. */
get totalDailyDue(): number {
return this.parties
.filter((party) => party.status !== 'CLOSED')
.reduce((sum, party) => sum + Number(party.dailyDepositAmount || 0), 0);
}
ngOnInit(): void {
this.organizationService.getDdsAgentSummary().subscribe((rows: any[]) => {
this.agentGroups = (rows || []).map((row: any) => ({
agentId: row.agentId ?? null,
agentCode: row.agentCode || '—',
agentName: row.agentName || 'No agent',
officeName: row.officeName || '',
accounts: Number(row.accounts || 0),
open: Number(row.open || 0),
closed: Number(row.closed || 0),
matured: Number(row.matured || 0),
maturedBalance: Number(row.maturedBalance || 0),
prematureClosed: Number(row.prematureClosed || 0),
dailyDue: Number(row.dailyDue || 0),
balance: Number(row.balance || 0),
collected: Number(row.collected || 0),
inArrears: Number(row.inArrears || 0)
}));
this.book = this.agentGroups.reduce(
(sum, group) => ({
accounts: sum.accounts + group.accounts,
open: sum.open + group.open,
closed: sum.closed + group.closed,
matured: sum.matured + group.matured,
maturedBalance: sum.maturedBalance + group.maturedBalance,
prematureClosed: sum.prematureClosed + group.prematureClosed,
balance: sum.balance + group.balance
}),
{ accounts: 0, open: 0, closed: 0, matured: 0, maturedBalance: 0, prematureClosed: 0, balance: 0 }
);
this.loaded = true;
// Coming back from an account (browser Back) reopens the agent it was opened under.
const agentParam = this.route.snapshot.queryParamMap.get('agent');
const reopen = agentParam ? this.agentGroups.find((group) => String(group.agentId) === agentParam) : null;
if (reopen) {
this.openAgent(reopen);
}
this.organizationService.getParties().subscribe((parties: any[]) => {
this.parties = parties || [];
this.dataSource = new MatTableDataSource(this.parties);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.filteredCount = this.parties.length;
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
});
this.loadDueForClosure();
@@ -385,197 +192,7 @@ export class PartyMasterComponent implements OnInit {
});
}
/**
* Matured and not yet paid out: marked MATURED by the nightly job, or open with its maturity date
* come (today included) before the job has run - the same test the server's summary makes.
*/
private static isMatured(p: any, todayMs: number): boolean {
if (p.status === 'MATURED') {
return true;
}
const maturity = PartyMasterComponent.dateOf(p.maturityDate);
return p.status === 'ACTIVE' && !!maturity && maturity.getTime() <= todayMs;
}
/** What a set of accounts adds up to; see BookFigures. */
private static figuresOf(parties: any[]): BookFigures {
const today = new Date();
today.setHours(0, 0, 0, 0);
const soon = today.getTime() + 30 * PartyMasterComponent.DAY_MS;
const products = new Map<string, number>();
const f: BookFigures = {
accounts: parties.length,
open: 0,
closed: 0,
dailyDue: 0,
balance: 0,
collected: 0,
expected: 0,
inArrears: 0,
arrearsAmount: 0,
inAdvance: 0,
interestAccrued: 0,
maturityValue: 0,
maturingSoon: 0,
matured: 0,
maturedBalance: 0,
closedAtMaturity: 0,
prematureClosed: 0,
minors: 0,
withNominee: 0,
byProduct: []
};
for (const p of parties) {
const product = p.subTypeDescription || p.subType || 'Other';
products.set(product, (products.get(product) || 0) + 1);
f.balance += Number(p.currentBalance || 0);
f.collected += Number(p.collectedToDate || 0);
if (isDdsClosed(p.status)) {
f.closed++;
if (p.status === 'PREMATURE_CLOSED') {
f.prematureClosed++;
} else {
f.closedAtMaturity++;
}
continue;
}
f.open++;
f.dailyDue += Number(p.dailyDepositAmount || 0);
f.expected += Number(p.expectedToDate || 0);
f.interestAccrued += Number(p.interestCalc || 0);
f.maturityValue += Number(p.maturityAmount || 0);
if (Number(p.arrearsDays || 0) > 0) {
f.inArrears++;
f.arrearsAmount += Number(p.recoverableAmount || 0);
} else if (Number(p.advanceDays || 0) > 0) {
f.inAdvance++;
}
const maturity = PartyMasterComponent.dateOf(p.maturityDate);
if (PartyMasterComponent.isMatured(p, today.getTime())) {
f.matured++;
f.maturedBalance += Number(p.currentBalance || 0);
} else if (maturity && maturity.getTime() <= soon) {
f.maturingSoon++;
}
if (String(p.adultMinor || '').toLowerCase() === 'minor') {
f.minors++;
}
if ((p.nominees || []).length > 0) {
f.withNominee++;
}
}
f.byProduct = [...products.entries()]
.map(
([
name,
count
]) => ({ name, count })
)
.sort((a, b) => b.count - a.count);
return f;
}
/** A date as the API sends it - [2026, 10, 27], "2026-10-27" or a timestamp - or null. */
private static dateOf(value: any): Date | null {
if (!value) {
return null;
}
const d = Array.isArray(value) ? new Date(value[0], value[1] - 1, value[2]) : new Date(value);
return isNaN(d.getTime()) ? null : d;
}
/** The agents the search box leaves, by code, name or branch. */
get visibleAgents(): AgentGroup[] {
const term = this.agentSearch.trim().toLowerCase();
if (!term) {
return this.agentGroups;
}
return this.agentGroups.filter((group) =>
`${group.agentCode} ${group.agentName} ${group.officeName}`.toLowerCase().includes(term)
);
}
/** The page of agents on show. */
get pagedAgents(): AgentGroup[] {
const start = this.agentPageIndex * this.agentPageSize;
return this.visibleAgents.slice(start, start + this.agentPageSize);
}
onAgentPage(event: PageEvent): void {
this.agentPageIndex = event.pageIndex;
this.agentPageSize = event.pageSize;
}
/** One agent's accounts, in the table below; the URL keeps the agent so Back returns to it. */
openAgent(group: AgentGroup): void {
this.selectedAgent = group;
this.agentFigures = null;
this.agentLoading = true;
this.dataSource.data = [];
this.dataSource.filter = '';
this.paginator?.firstPage();
// Only this agent's accounts are fetched - the book is never sent whole.
this.organizationService.getPartiesOfAgent(group.agentId ?? '').subscribe({
next: (parties: any[]) => {
// A different agent may have been opened while this one was loading.
if (this.selectedAgent !== group) {
return;
}
this.agentParties = parties || [];
this.cardView = 'all';
this.cardKey = 'all';
this.dataSource.data = this.agentParties;
this.agentFigures = PartyMasterComponent.figuresOf(this.agentParties);
this.agentLoading = false;
},
error: () => {
if (this.selectedAgent === group) {
this.agentFigures = PartyMasterComponent.figuresOf([]);
this.agentLoading = false;
}
}
});
this.router.navigate([], {
relativeTo: this.route,
queryParams: { agent: group.agentId },
replaceUrl: true
});
}
backToAgents(): void {
this.selectedAgent = null;
this.agentParties = [];
this.cardView = 'all';
this.cardKey = 'all';
this.agentFigures = null;
this.agentLoading = false;
this.dataSource.data = [];
this.router.navigate([], { relativeTo: this.route, queryParams: {}, replaceUrl: true });
}
/** The search box searches agents while agents are listed, and the open agent's accounts after. */
applyFilter(filterValue: string): void {
if (this.selectedAgent) {
this.dataSource.filter = filterValue.trim().toLowerCase();
} else {
this.agentSearch = filterValue;
this.agentPageIndex = 0;
}
}
/** How an account stands against its daily schedule: behind, ahead or on track. */
scheduleOf(p: any): { label: string; tone: 'bad' | 'good' | 'ok' | 'off' } {
if (isDdsClosed(p.status)) {
return { label: 'Closed', tone: 'off' };
}
const behind = Number(p.arrearsDays || 0);
if (behind > 0) {
return { label: `${behind} day${behind === 1 ? '' : 's'} behind`, tone: 'bad' };
}
const ahead = Number(p.advanceDays || 0);
if (ahead > 0) {
return { label: `${ahead} day${ahead === 1 ? '' : 's'} ahead`, tone: 'good' };
}
return { label: 'On track', tone: 'ok' };
}
}
@@ -202,7 +202,7 @@ export class PayoutDialogComponent implements OnInit {
const interest =
signed('INTEREST_POST') +
signed('OD_INTEREST_POST') +
signed('PREMATURE_AGENT_TOPUP') +
signed('PREMATURE_AGENT_TOP_UP') +
signed('PREMATURE_INT_RECOVERY');
const balance = Number(this.data.currentBalance || 0);
// Anything the four lines above do not cover - an opening balance brought over, say - so the lines
@@ -6,116 +6,63 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<!-- What is being closed, and what it becomes: an ACTIVE account turns PREMATURE_CLOSED. -->
<div class="pcd-head">
<span class="pcd-head__ic"><fa-icon icon="times-circle"></fa-icon></span>
<div class="pcd-head__text">
<h2 class="pcd-head__title">Premature Closure</h2>
<span class="pcd-head__sub">{{ data.partyCode }} · {{ data.partyName }}</span>
</div>
<div class="pcd-head__status">
<span class="pcd-status pcd-status--active">Active</span>
<fa-icon icon="arrow-right"></fa-icon>
<span class="pcd-status pcd-status--premature">Premature Closed</span>
</div>
</div>
<h2 mat-dialog-title>Close {{ data.partyCode }} early</h2>
<mat-dialog-content class="pcd-body">
<mat-dialog-content>
@if (loading) {
<mat-progress-bar mode="indeterminate" class="pcd-loading"></mat-progress-bar>
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}
<!--
The account as it stands, so the clerk is choosing against real figures rather than from memory.
The term still to run is the whole reason there is a charge at all.
-->
<div class="pcd-tiles">
<div class="pcd-tile">
<small>Balance</small>
<b>₹{{ data.currentBalance | number: '1.2-2' }}</b>
<div class="pcd-summary">
<div class="pcd-row">
<span>Customer</span>
<strong>{{ data.partyName }}</strong>
</div>
<div class="pcd-tile">
<small>Scheme rate</small>
<b>{{ data.interestRate | number: '1.0-2' }}%</b>
<div class="pcd-row">
<span>Balance</span>
<strong class="pcd-amount">{{ data.currentBalance | number: '1.2-2' }}</strong>
</div>
<div class="pcd-tile">
<small>Opened</small>
<b>{{ data.openingDate | dateFormat }}</b>
<div class="pcd-row">
<span>Matures on</span>
<strong>{{ data.maturityDate | dateFormat }}</strong>
</div>
<div class="pcd-tile">
<small>Matures on</small>
<b>{{ data.maturityDate | dateFormat }}</b>
<div class="pcd-row">
<span>Scheme rate</span>
<strong>{{ data.interestRate | number: '1.0-2' }}%</strong>
</div>
</div>
@if (termDays > 0) {
<div class="pcd-term">
<div class="pcd-term__bar"><span [style.width.%]="termProgress"></span></div>
<div class="pcd-term__text">
<span
><b>{{ daysRun }}</b> of {{ termDays }} days run ({{ termProgress | number: '1.0-0' }}%)</span
>
<span
><b>{{ daysLeft }}</b> {{ daysLeft === 1 ? 'day' : 'days' }} left to maturity</span
>
</div>
</div>
}
@if (pendingInterest > 0) {
<div class="pcd-alert">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span
>₹{{ pendingInterest | number: '1.2-2' }} interest is calculated but not posted yet. Run
<b>Interest Posting</b> first — the closure is refused until it is in the balance.</span
>
</div>
}
<div class="pcd-grid">
<div class="pcd-main">
<!-- Step 1 -->
<h4 class="pcd-step"><span class="pcd-step__no">1</span> Who bears the cost of closing early?</h4>
<h4 class="pcd-question">Who bears the cost of closing early?</h4>
<mat-radio-group class="pcd-choices" [(ngModel)]="borneBy">
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'CUSTOMER'">
<mat-radio-button value="CUSTOMER"></mat-radio-button>
<span class="pcd-choice__ic"><fa-icon icon="user"></fa-icon></span>
<div class="pcd-choice__body">
<div class="pcd-choice__title">Customer</div>
<div class="pcd-choice-body">
<div class="pcd-choice-title">Customer</div>
@if (scheme) {
<div class="pcd-choice__detail">
<div class="pcd-choice-detail">
Interest recalculated at
<strong>{{ reducedRate | number: '1.0-2' }}%</strong>
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken
back before the balance is paid out.
instead of {{ data.interestRate | number: '1.0-2' }}% for the whole period, and the difference taken back
before the balance is paid out.
</div>
} @else {
<div class="pcd-choice__detail">Their interest is recalculated at the scheme's reduced rate.</div>
<div class="pcd-choice-detail">Their interest is recalculated at the scheme's reduced rate.</div>
}
<div class="pcd-choice__gets">
Customer gets <b>₹{{ customerPayout | number: '1.2-2' }}</b>
@if (customerRecovery > 0) {
<span class="pcd-neg">(₹{{ customerRecovery | number: '1.2-2' }} interest taken back)</span>
}
</div>
</div>
</label>
<label class="pcd-choice" [class.pcd-choice--on]="borneBy === 'AGENT'">
<mat-radio-button value="AGENT"></mat-radio-button>
<span class="pcd-choice__ic"><fa-icon icon="user-tie"></fa-icon></span>
<div class="pcd-choice__body">
<div class="pcd-choice__title">Agent — {{ data.agentCode }}</div>
<div class="pcd-choice__detail">
The customer keeps a full month's interest, and the agent funds the days left in this month. The balance
is paid out in full.
</div>
<div class="pcd-choice__gets">
Customer gets <b>₹{{ agentPayout | number: '1.2-2' }}</b>
@if (agentTopUp > 0) {
<span class="pcd-pos">(+₹{{ agentTopUp | number: '1.2-2' }} paid by agent)</span>
}
<div class="pcd-choice-body">
<div class="pcd-choice-title">Agent — {{ data.agentCode }}</div>
<div class="pcd-choice-detail">
The customer keeps a full month's interest, and the agent funds the days left in this month. The balance is
paid out in full.
</div>
</div>
</label>
@@ -126,44 +73,20 @@
a clerk who did not know that would be surprised by it afterwards.
-->
@if (scheme && scheme.prematureAgentCommissionPenalty > 0) {
<p class="pcd-info">
<fa-icon icon="info-circle"></fa-icon>
<span
>Either way, {{ data.agentCode }} is charged
<p class="pcd-note">
Either way, {{ data.agentCode }} is charged
<strong>{{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}%</strong>
of the commission owed to them — the round was not kept going.</span
>
of the commission owed to them — the round was not kept going.
</p>
}
<!-- Step 2 -->
<h4 class="pcd-step"><span class="pcd-step__no">2</span> Where should the balance go?</h4>
<h4 class="pcd-question">Where should the balance go?</h4>
<mat-radio-group class="pcd-choices pcd-choices--two" [(ngModel)]="destination">
<label
class="pcd-choice"
[class.pcd-choice--on]="destination === 'own'"
[class.pcd-choice--off]="ownAccounts.length === 0"
>
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0"></mat-radio-button>
<div class="pcd-choice__body">
<div class="pcd-choice__title">Customer's account</div>
<div class="pcd-choice__detail">
@if (ownAccounts.length > 0) {
<mat-radio-group class="pcd-destinations" [(ngModel)]="destination">
<mat-radio-button value="own" [disabled]="ownAccounts.length === 0">
Into another account of theirs at this bank
} @else {
No other account here
}
</div>
</div>
</label>
<label class="pcd-choice" [class.pcd-choice--on]="destination === 'other'">
<mat-radio-button value="other"></mat-radio-button>
<div class="pcd-choice__body">
<div class="pcd-choice__title">Park in Sundry</div>
<div class="pcd-choice__detail">Held in their name until it is paid to them</div>
</div>
</label>
</mat-radio-button>
<mat-radio-button value="other">Park in Sundry (no account here)</mat-radio-button>
</mat-radio-group>
@if (destination === 'own' && ownAccounts.length > 0) {
@@ -209,155 +132,44 @@
</mat-form-field>
}
@if (ownAccounts.length === 0 && !loading) {
<p class="pcd-note">
This customer has no other account here, so the balance is parked in the Sundry account chosen above, in their
name until it is paid to them.
</p>
}
<!--
Inside the lock-in the scheme does not allow closing early, so this is asked for rather than done:
the reason goes to a manager, who approves this one account. The scheme's lock-in stays as it is.
-->
@if (withinLockin) {
<h4 class="pcd-step"><span class="pcd-step__no">3</span> Reason for closing within lock-in</h4>
<div class="pcd-lockin">
<p class="pcd-lockin__title">
<fa-icon icon="lock"></fa-icon> Within lock-in until {{ closableFrom | dateFormat }}
</p>
<p class="pcd-lockin__note">
<p class="pcd-lockin-title">Within lock-in until {{ closableFrom | dateFormat }}</p>
<p class="pcd-note">
{{ data.partyCode }} opened on {{ data.openingDate | dateFormat }} and the scheme locks it in for
{{ scheme?.prematureLockinMonths }} months. It can be closed early only if a manager approves it.
</p>
<mat-form-field appearance="outline" class="pcd-field">
<mat-label>Reason</mat-label>
<mat-label>Reason for closing within lock-in</mat-label>
<textarea matInput rows="2" maxlength="250" required [(ngModel)]="lockinReason"></textarea>
<mat-hint align="end">{{ lockinReason.length }}/250</mat-hint>
</mat-form-field>
</div>
}
<div class="pcd-warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<span>
<p class="pcd-warn">
@if (withinLockin) {
Once a manager approves, the balance is paid out and the account is closed automatically as
<b>Premature Closed</b>. It cannot be reopened.
Once approved, this closes the account. It cannot be reopened.
} @else {
The balance is paid out and the account is closed automatically as <b>Premature Closed</b>. It cannot be
reopened.
This closes the account. It cannot be reopened.
}
</span>
</div>
</div>
<!--
The settlement worked out with the server's own formulas, on today's figures, so the clerk can
tell the customer what they will receive before choosing. The server applies the final figures.
-->
<aside class="pcd-calc">
<h4 class="pcd-calc__title"><fa-icon icon="calculator"></fa-icon> Payout calculation</h4>
<div class="pcd-calc__rows">
<div class="pcd-calc__row">
<span>Deposits collected</span>
<b>₹{{ deposits | number: '1.2-2' }}</b>
</div>
<div class="pcd-calc__row">
<span>Interest credited so far</span>
<b>
@if (interestCredited === null) {
…
} @else {
₹{{ interestCredited | number: '1.2-2' }}
}
</b>
</div>
<div class="pcd-calc__row pcd-calc__row--sum">
<span>Current balance</span>
<b>₹{{ balance | number: '1.2-2' }}</b>
</div>
@if (borneBy === 'CUSTOMER') {
<div class="pcd-calc__row pcd-neg">
<span
>Interest taken back
<small
>₹{{ interestCredited ?? 0 | number: '1.2-2' }} ×
{{ data.interestRate - reducedRate | number: '1.0-2' }}% ÷
{{ data.interestRate | number: '1.0-2' }}%</small
></span
>
<b>− ₹{{ customerRecovery | number: '1.2-2' }}</b>
</div>
} @else if (borneBy === 'AGENT') {
<div class="pcd-calc__row pcd-pos">
<span
>Interest funded by {{ data.agentCode }}
<small
>₹{{ balance | number: '1.2-2' }} × {{ agentTopUpDays }} days ×
{{ data.interestRate | number: '1.0-2' }}% ÷ 365</small
></span
>
<b>+ ₹{{ agentTopUp | number: '1.2-2' }}</b>
</div>
}
</div>
<div class="pcd-calc__total" [class.pcd-calc__total--empty]="payout === null">
<span>Customer receives</span>
@if (payout !== null) {
<b>₹{{ payout | number: '1.2-2' }}</b>
} @else {
<em>Choose who bears the cost</em>
}
</div>
<div class="pcd-calc__to"><fa-icon icon="arrow-right"></fa-icon> {{ destinationLabel }}</div>
<div class="pcd-calc__agent">
<div class="pcd-calc__sub">Charged to agent {{ data.agentCode }}</div>
@if (borneBy === 'AGENT') {
<div class="pcd-calc__row">
<span>Interest top-up</span>
<b>₹{{ agentTopUp | number: '1.2-2' }}</b>
</div>
}
<div class="pcd-calc__row">
<span
>Commission penalty
@if (scheme?.prematureAgentCommissionPenalty > 0) {
<small
>{{ scheme.prematureAgentCommissionPenalty | number: '1.0-2' }}% of ₹{{
agentCommission ?? 0 | number: '1.2-2'
}}
owed</small
>
}
</span>
<b>₹{{ commissionPenalty | number: '1.2-2' }}</b>
</div>
<div class="pcd-calc__row pcd-calc__row--sum">
<span>Total charged to agent</span>
<b>₹{{ (borneBy === 'AGENT' ? agentTopUp : 0) + commissionPenalty | number: '1.2-2' }}</b>
</div>
</div>
<p class="pcd-calc__foot">
Worked out on today's figures with the scheme's terms. The server applies the final amounts on the closing day.
</p>
</aside>
</div>
</mat-dialog-content>
<mat-dialog-actions class="pcd-actions">
<span class="pcd-actions__hint">
@if (!borneBy) {
Choose who bears the cost
} @else if (destination === 'own' && !selectedAccountNo) {
Pick the account to credit
} @else if (destination === 'other' && !sundryGlAccountId) {
Pick the Sundry GL account
} @else if (withinLockin && !lockinReason.trim()) {
Give the reason for closing within lock-in
}
</span>
<button mat-stroked-button mat-dialog-close type="button">Cancel</button>
<button mat-flat-button color="warn" type="button" class="pcd-submit" [disabled]="!canClose" (click)="close()">
<fa-icon [icon]="withinLockin ? 'paper-plane' : 'times-circle'"></fa-icon>
{{ withinLockin ? 'Send for Approval' : 'Close Prematurely' }}
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button">Cancel</button>
<button mat-raised-button color="warn" type="button" [disabled]="!canClose" (click)="close()">
{{ withinLockin ? 'Send for Approval' : 'Close Account' }}
</button>
</mat-dialog-actions>
@@ -6,183 +6,40 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* What is being closed, and the status it moves to */
.pcd-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 18px 24px;
background: linear-gradient(135deg, #1e3a8a, #4338ca);
color: #fff;
&__ic {
display: grid;
flex-shrink: 0;
place-items: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: rgb(255 255 255 / 15%);
font-size: 18px;
}
&__text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
&__title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: #fff;
}
&__sub {
overflow: hidden;
font-size: 0.82rem;
color: rgb(255 255 255 / 75%);
white-space: nowrap;
text-overflow: ellipsis;
}
&__status {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: rgb(255 255 255 / 70%);
}
}
.pcd-status {
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
white-space: nowrap;
&--active {
border: 1px solid rgb(167 243 208 / 40%);
background: rgb(16 185 129 / 25%);
color: #a7f3d0;
}
&--premature {
border: 1px solid rgb(254 202 202 / 40%);
background: rgb(239 68 68 / 30%);
color: #fecaca;
}
}
.pcd-body {
padding-top: 16px !important;
}
.pcd-loading {
margin-bottom: 12px;
}
/* The account as it stands, so the choice below is made against figures rather than from memory. */
.pcd-tiles {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
.pcd-summary {
margin: 4px 0 20px;
padding: 12px 14px;
border-radius: 6px;
background: rgb(0 0 0 / 3%);
}
.pcd-tile {
.pcd-row {
display: flex;
flex-direction: column;
gap: 2px;
padding: 9px 11px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
justify-content: space-between;
gap: 16px;
padding: 3px 0;
font-size: 0.85rem;
small {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
span {
color: rgb(0 0 0 / 60%);
}
}
b {
font-size: 0.92rem;
color: #0f172a;
.pcd-amount {
font-variant-numeric: tabular-nums;
}
}
/* How much of the term has run - the reason closing now costs anything */
.pcd-term {
margin-top: 10px;
&__bar {
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #e2e8f0;
> span {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #4338ca, #6366f1);
}
}
&__text {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
margin-top: 5px;
font-size: 0.76rem;
color: #64748b;
b {
color: #0f172a;
}
}
}
.pcd-step {
display: flex;
align-items: center;
gap: 8px;
.pcd-question {
margin: 20px 0 10px;
font-size: 0.9rem;
font-weight: 600;
color: #0f172a;
&__no {
display: grid;
flex-shrink: 0;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #eef2ff;
color: #4338ca;
font-size: 0.75rem;
font-weight: 700;
}
}
.pcd-choices {
display: flex;
flex-direction: column;
gap: 8px;
&--two {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
gap: 10px;
}
/*
@@ -192,69 +49,42 @@
.pcd-choice {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
gap: 4px;
padding: 10px 12px 12px;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 6px;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
&:hover {
border-color: #c7d2fe;
background: #f8fafc;
background: rgb(0 0 0 / 2%);
}
}
&--on,
&--on:hover {
border-color: #4338ca;
background: #eef2ff;
box-shadow: 0 0 0 1px #4338ca;
.pcd-choice--on {
border-color: #b45309;
background: rgb(180 83 9 / 5%);
}
&--off {
cursor: not-allowed;
opacity: 0.55;
.pcd-choice-body {
padding-top: 2px;
}
&__ic {
display: grid;
flex-shrink: 0;
place-items: center;
width: 30px;
height: 30px;
margin-top: 5px;
border-radius: 8px;
background: #f1f5f9;
color: #475569;
font-size: 13px;
}
&--on &__ic {
background: #4338ca;
color: #fff;
}
&__body {
min-width: 0;
padding-top: 8px;
}
&__title {
font-size: 0.88rem;
.pcd-choice-title {
font-weight: 600;
color: #0f172a;
font-size: 0.88rem;
}
&__detail {
.pcd-choice-detail {
margin-top: 3px;
font-size: 0.8rem;
line-height: 1.5;
color: #475569;
color: rgb(0 0 0 / 65%);
}
.pcd-destinations {
display: flex;
flex-direction: column;
gap: 6px;
}
.pcd-field {
@@ -262,290 +92,41 @@
margin-top: 12px;
}
.pcd-info {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 10px 0 0;
padding: 8px 12px;
border-radius: 8px;
background: #eff6ff;
color: #1e40af;
.pcd-note {
margin: 12px 0 0;
font-size: 0.8rem;
line-height: 1.5;
fa-icon {
margin-top: 2px;
color: rgb(0 0 0 / 65%);
}
/* The one step here with no way back, so it is said plainly rather than left to the button colour. */
.pcd-warn {
margin: 18px 0 0;
padding: 9px 12px;
border-radius: 6px;
border: 1px solid rgb(185 28 28 / 25%);
background: rgb(185 28 28 / 6%);
color: #b91c1c;
font-size: 0.8rem;
font-weight: 500;
}
/* Inside the lock-in: a request for a manager, not a closure, so it is set apart from the terms above. */
.pcd-lockin {
margin-top: 18px;
padding: 12px 14px 4px;
border-radius: 8px;
border: 1px solid rgb(180 83 9 / 30%);
border-radius: 10px;
background: rgb(245 158 11 / 8%);
}
&__title {
display: flex;
align-items: center;
gap: 6px;
.pcd-lockin-title {
margin: 0;
font-size: 0.85rem;
font-weight: 600;
color: #b45309;
}
&__note {
margin: 6px 0 0;
font-size: 0.8rem;
line-height: 1.5;
color: #78350f;
}
}
/* The one step here with no way back, so it is said plainly rather than left to the button colour. */
.pcd-warn {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 18px 0 4px;
padding: 10px 12px;
border: 1px solid rgb(185 28 28 / 25%);
border-radius: 10px;
background: rgb(185 28 28 / 6%);
color: #b91c1c;
font-size: 0.8rem;
line-height: 1.5;
fa-icon {
margin-top: 2px;
}
}
.pcd-actions {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px 16px !important;
border-top: 1px solid #e2e8f0;
&__hint {
flex: 1;
font-size: 0.78rem;
color: #64748b;
}
}
.pcd-submit fa-icon {
margin-right: 6px;
}
.po-hint-warn {
color: #c62828;
}
/* Interest not yet posted: the closure will be refused, so it is said before anything is chosen */
.pcd-alert {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
padding: 9px 12px;
border: 1px solid rgb(180 83 9 / 30%);
border-radius: 10px;
background: rgb(245 158 11 / 10%);
color: #92400e;
font-size: 0.8rem;
line-height: 1.5;
fa-icon {
margin-top: 2px;
}
}
/* The choices on the left, the payout they lead to on the right */
.pcd-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 22px;
align-items: start;
}
.pcd-main {
min-width: 0;
}
.pcd-choice__gets {
margin-top: 6px;
font-size: 0.8rem;
color: #334155;
b {
font-size: 0.9rem;
color: #0f172a;
}
}
.pcd-neg {
color: #b91c1c !important;
}
.pcd-pos {
color: #15803d !important;
}
.pcd-calc {
position: sticky;
top: 0;
margin-top: 20px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
&__title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 10px;
font-size: 0.9rem;
font-weight: 700;
color: #0f172a;
fa-icon {
color: #4338ca;
}
}
&__rows {
display: flex;
flex-direction: column;
}
&__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
font-size: 0.82rem;
color: #475569;
span {
display: flex;
flex-direction: column;
}
small {
margin-top: 1px;
font-size: 0.72rem;
color: #94a3b8;
}
b {
color: inherit;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
&--sum {
margin-top: 2px;
border-top: 1px dashed #cbd5e1;
color: #0f172a;
font-weight: 600;
}
}
&__total {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding: 12px 14px;
border-radius: 10px;
background: linear-gradient(135deg, #1e3a8a, #4338ca);
color: rgb(255 255 255 / 85%);
font-size: 0.82rem;
font-weight: 600;
b {
font-size: 1.25rem;
color: #fff;
font-variant-numeric: tabular-nums;
}
em {
font-size: 0.78rem;
font-style: normal;
color: rgb(255 255 255 / 70%);
}
&--empty {
background: #e2e8f0;
color: #475569;
em {
color: #64748b;
}
}
}
&__to {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
overflow: hidden;
font-size: 0.76rem;
color: #64748b;
white-space: nowrap;
text-overflow: ellipsis;
}
&__agent {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
}
&__sub {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
}
&__foot {
margin: 12px 0 0;
font-size: 0.72rem;
line-height: 1.5;
color: #94a3b8;
}
}
@media (width <= 760px) {
.pcd-grid {
grid-template-columns: minmax(0, 1fr);
}
.pcd-calc {
position: static;
}
}
@media (width <= 600px) {
.pcd-tiles {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pcd-choices--two {
grid-template-columns: minmax(0, 1fr);
}
.pcd-head__status {
width: 100%;
}
}
@@ -18,7 +18,6 @@ import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import { OrganizationService } from 'app/organization/organization.service';
@@ -57,7 +56,6 @@ import { DateFormatPipe } from 'app/pipes/date-format.pipe';
MatFormFieldModule,
MatInputModule,
MatProgressBarModule,
FaIconComponent,
DateFormatPipe
],
templateUrl: './premature-close-dialog.component.html',
@@ -176,113 +174,6 @@ export class PrematureCloseDialogComponent implements OnInit {
},
error: () => (this.scheme = null)
});
// Interest already credited, which the customer's option scales back - summed the way the server
// sums it, from the account's own postings.
if (this.data.id) {
this.organizationService.getPartyTransactionsOfParty(this.data.id).subscribe({
next: (txns: any) => {
const list: any[] = Array.isArray(txns) ? txns : txns?.pageItems || [];
this.interestCredited = list
.filter((t) => PrematureCloseDialogComponent.INTEREST_TYPES.includes(t.transactionType))
.reduce((sum, t) => sum + (t.direction === 'DEBIT' ? -1 : 1) * Number(t.amount || 0), 0);
},
error: () => (this.interestCredited = null)
});
}
// The commission the agent is owed now, which the scheme's penalty is a share of.
if (this.data.agentId) {
this.organizationService.getAgent(this.data.agentId).subscribe({
next: (agent: any) => (this.agentCommission = Number(agent?.commission || 0)),
error: () => (this.agentCommission = null)
});
}
}
private static readonly INTEREST_TYPES = [
'INTEREST_POST',
'PREMATURE_INT_RECOVERY',
'PREMATURE_AGENT_TOPUP'
];
/** Interest credited to the account so far; null until read (or if it could not be). */
interestCredited: number | null = null;
/** What the agent is owed in commission today; null until read. */
agentCommission: number | null = null;
private static round2(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
get balance(): number {
return Number(this.data.currentBalance || 0);
}
/** Interest calculated but not posted - the server refuses to close until it is posted. */
get pendingInterest(): number {
return Number(this.data.interestCalc || 0);
}
/** What the customer paid in: the balance less the interest credited on it. */
get deposits(): number {
const collected = Number(this.data.collectedToDate || 0);
return collected > 0 ? collected : Math.max(0, this.balance - (this.interestCredited ?? 0));
}
/** Customer bears it: credited interest scaled by the share of the rate given up. */
get customerRecovery(): number {
const rate = Number(this.data.interestRate || 0);
const reduction = Number(this.scheme?.prematureInterestReduction || 0);
const credited = this.interestCredited ?? 0;
if (rate <= 0 || reduction <= 0 || credited <= 0) {
return 0;
}
return PrematureCloseDialogComponent.round2((credited * Math.min(reduction, rate)) / rate);
}
get customerPayout(): number {
return PrematureCloseDialogComponent.round2(this.balance - this.customerRecovery);
}
/** Days from today to the month's end, today counted - the days the agent funds. */
get agentTopUpDays(): number {
const today = new Date();
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate();
return lastDay - today.getDate() + 1;
}
/** Agent bears it: interest on the balance for the rest of this month, at the scheme rate. */
get agentTopUp(): number {
const rate = Number(this.data.interestRate || 0);
if (rate <= 0 || this.balance <= 0) {
return 0;
}
return PrematureCloseDialogComponent.round2((this.balance * this.agentTopUpDays * rate) / 36500);
}
get agentPayout(): number {
return PrematureCloseDialogComponent.round2(this.balance + this.agentTopUp);
}
/** The share of the agent's commission taken whichever way it is settled. */
get commissionPenalty(): number {
const rate = Number(this.scheme?.prematureAgentCommissionPenalty || 0);
if (rate <= 0 || !this.agentCommission) {
return 0;
}
return PrematureCloseDialogComponent.round2((this.agentCommission * rate) / 100);
}
/** What the customer receives on the option picked, or null before one is picked. */
get payout(): number | null {
return this.borneBy === 'CUSTOMER' ? this.customerPayout : this.borneBy === 'AGENT' ? this.agentPayout : null;
}
get destinationLabel(): string {
if (this.destination === 'own') {
return this.selectedAccountNo ? `Account ${this.selectedAccountNo}` : 'Customer account (not picked)';
}
return this.chosenSundry ? `Sundry ${this.sundryLabel(this.chosenSundry)}` : 'Sundry GL (not picked)';
}
/** The rate the customer would be left on if they carry it. Never below nothing. */
@@ -292,48 +183,6 @@ export class PrematureCloseDialogComponent implements OnInit {
return Math.max(0, rate - reduction);
}
/** The API sends dates as [year, month, day]; month is 1-based there and 0-based in Date. */
private static toDate(value: any): Date | null {
const date =
Array.isArray(value) && value.length >= 3
? new Date(value[0], value[1] - 1, value[2])
: value
? new Date(value)
: null;
return date && !isNaN(date.getTime()) ? date : null;
}
private static readonly DAY_MS = 24 * 60 * 60 * 1000;
/** The whole term in days, opening to maturity; 0 when either date is missing. */
get termDays(): number {
const opened = PrematureCloseDialogComponent.toDate(this.data.openingDate);
const matures = PrematureCloseDialogComponent.toDate(this.data.maturityDate);
return opened && matures
? Math.max(0, Math.round((matures.getTime() - opened.getTime()) / PrematureCloseDialogComponent.DAY_MS))
: 0;
}
/** Days of the term already run, today counted. */
get daysRun(): number {
const opened = PrematureCloseDialogComponent.toDate(this.data.openingDate);
if (!opened) {
return 0;
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const run = Math.round((today.getTime() - opened.getTime()) / PrematureCloseDialogComponent.DAY_MS) + 1;
return Math.min(this.termDays, Math.max(0, run));
}
get daysLeft(): number {
return Math.max(0, this.termDays - this.daysRun);
}
get termProgress(): number {
return this.termDays > 0 ? (this.daysRun / this.termDays) * 100 : 0;
}
/** Why the lock-in should be waived for this account - required when it is inside it. */
lockinReason = '';
@@ -344,8 +193,8 @@ export class PrematureCloseDialogComponent implements OnInit {
*/
get closableFrom(): Date | null {
const months = Number(this.scheme?.prematureLockinMonths || 0);
const opened = PrematureCloseDialogComponent.toDate(this.data.openingDate);
if (months <= 0 || !opened) {
const opened = this.data.openingDate ? new Date(this.data.openingDate) : null;
if (months <= 0 || !opened || isNaN(opened.getTime())) {
return null;
}
const closable = new Date(opened);
@@ -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;
}

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