Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bf74afe24 | ||
|
|
885b5897af | ||
|
|
f0b8f7f5f4 | ||
|
|
1bf441114e | ||
|
|
896e3fd7e8 | ||
|
|
f9748036c3 | ||
|
|
7eac2dcf3b | ||
|
|
1b70fb4534 |
@@ -139,6 +139,13 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="limit">
|
||||
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Sanctioned Limit</th>
|
||||
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
|
||||
{{ getCurrencySymbol(row) }} {{ row.overdraftLimit | formatNumber }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="balance">
|
||||
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
|
||||
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
|
||||
|
||||
@@ -34,7 +34,7 @@ import { FormatNumberPipe } from '../../pipes/format-number.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { AccountsService } from '../accounts.service';
|
||||
|
||||
export type AccountType = 'savings' | 'current' | 'fixed-deposits' | 'recurring-deposits';
|
||||
export type AccountType = 'savings' | 'current' | 'cc' | 'fixed-deposits' | 'recurring-deposits';
|
||||
|
||||
interface AccountConfig {
|
||||
label: string;
|
||||
@@ -119,6 +119,13 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
typeLabel: 'CA',
|
||||
typeBadgeClass: 'dtype--ca'
|
||||
},
|
||||
cc: {
|
||||
label: 'CC Accounts',
|
||||
icon: 'hand-holding-usd',
|
||||
accentClass: 'accent--current',
|
||||
typeLabel: 'CC',
|
||||
typeBadgeClass: 'dtype--ca'
|
||||
},
|
||||
'fixed-deposits': {
|
||||
label: 'Fixed Deposit Accounts',
|
||||
icon: 'lock',
|
||||
@@ -142,6 +149,24 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
ngOnInit() {
|
||||
this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => {
|
||||
this.accountType = data['accountType'] as AccountType;
|
||||
this.displayedColumns = this.accountType === 'cc' ? [
|
||||
'accountNo',
|
||||
'depositType',
|
||||
'clientName',
|
||||
'productName',
|
||||
'limit',
|
||||
'balance',
|
||||
'status',
|
||||
'actions'
|
||||
] : [
|
||||
'accountNo',
|
||||
'depositType',
|
||||
'clientName',
|
||||
'productName',
|
||||
'balance',
|
||||
'status',
|
||||
'actions'
|
||||
];
|
||||
this.pageIndex = 0;
|
||||
this.searchControl.setValue('', { emitEvent: false });
|
||||
this.loadAccounts();
|
||||
@@ -183,9 +208,12 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
const toVal = this.toDateControl.value;
|
||||
const bothDatesSet = !!(fromVal && toVal);
|
||||
|
||||
// CC accounts come from the server already narrowed to those that allow an overdraft; nothing else is read.
|
||||
const isCc = this.accountType === 'cc';
|
||||
const params: import('../accounts.service').AccountListParams = {
|
||||
offset: this.pageIndex * this.pageSize,
|
||||
limit: this.pageSize,
|
||||
offset: isCc ? 0 : this.pageIndex * this.pageSize,
|
||||
limit: isCc ? 500 : this.pageSize,
|
||||
...(isCc && { overdraftOnly: true }),
|
||||
accountNo: this.searchType === 'accountNo' ? search || undefined : undefined,
|
||||
clientName: this.searchType === 'clientName' ? search || undefined : undefined,
|
||||
orderBy: 'id',
|
||||
@@ -199,7 +227,7 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
const obs$ =
|
||||
this.accountType === 'savings'
|
||||
? this.accountsService.getSavingsAccounts(params)
|
||||
: this.accountType === 'current'
|
||||
: this.accountType === 'current' || this.accountType === 'cc'
|
||||
? this.accountsService.getCurrentAccounts(params)
|
||||
: this.accountType === 'fixed-deposits'
|
||||
? this.accountsService.getFixedDepositAccounts(params)
|
||||
@@ -239,7 +267,7 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
this.totalRecords = Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length);
|
||||
this.totalRecords = isCc || Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length);
|
||||
this.dataSource.data = items;
|
||||
this.isLoading = false;
|
||||
},
|
||||
@@ -307,6 +335,13 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
||||
id,
|
||||
'general'
|
||||
],
|
||||
cc: [
|
||||
'/clients',
|
||||
account.clientId,
|
||||
'current-accounts',
|
||||
id,
|
||||
'general'
|
||||
],
|
||||
'fixed-deposits': [
|
||||
'/clients',
|
||||
account.clientId,
|
||||
|
||||
@@ -36,6 +36,12 @@ const routes: Routes = [
|
||||
component: AccountsListComponent,
|
||||
data: { accountType: 'current', title: 'Current Accounts', breadcrumb: 'Current Accounts' }
|
||||
},
|
||||
{
|
||||
// Cash Credit: current accounts opened on a product that allows an overdraft.
|
||||
path: 'cc',
|
||||
component: AccountsListComponent,
|
||||
data: { accountType: 'cc', title: 'CC Accounts', breadcrumb: 'CC Accounts' }
|
||||
},
|
||||
{
|
||||
path: 'fixed-deposits',
|
||||
component: AccountsListComponent,
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface AccountListParams {
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
asOnDate?: string;
|
||||
/** Current accounts only: just the Cash Credit (overdraft) accounts, filtered by the server. */
|
||||
overdraftOnly?: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -55,6 +57,7 @@ export class AccountsService {
|
||||
if (params.fromDate) httpParams = httpParams.set('fromDate', params.fromDate);
|
||||
if (params.toDate) httpParams = httpParams.set('toDate', params.toDate);
|
||||
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
|
||||
if (params.overdraftOnly) httpParams = httpParams.set('overdraftOnly', 'true');
|
||||
return this.http.get('/currentaccounts', { params: httpParams });
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,16 @@ export class AmlService {
|
||||
return this.http.get<any>('/aml/cc-tracking', { params: new HttpParams().set('days', String(days)) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Every credit and debit on one CC account over the last `days` days, with the mode and counterparty each payment
|
||||
* recorded, totalled by mode and by counterparty.
|
||||
*/
|
||||
getCcAccountTrail(accountId: number, days = 30): Observable<any> {
|
||||
return this.http.get<any>(`/aml/cc-tracking/${accountId}/trail`, {
|
||||
params: new HttpParams().set('days', String(days))
|
||||
});
|
||||
}
|
||||
|
||||
/** month format: yyyy-MM */
|
||||
generateCtrReport(month: string): Observable<any> {
|
||||
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { ApiMappingRow, KeyValue, kv } from './api-management.service';
|
||||
|
||||
/**
|
||||
* Reads what a vendor's documentation or a Postman / browser / cURL capture gives - in any of these shapes - and turns
|
||||
* it into the pieces of an API: method, URL, path, query parameters, headers, content type, payload, response status,
|
||||
* response headers, response body, and a first guess at the CBS field mapping. Nothing is sent anywhere.
|
||||
*
|
||||
* Understood:
|
||||
* - a cURL command
|
||||
* - a Postman collection or a single Postman request / item (with its saved responses)
|
||||
* - a HAR capture
|
||||
* - an "API definition" object: { method, url | path, query, headers, body, status, ... }
|
||||
* - a bare JSON payload (treated as the request body, or the response body on the response side)
|
||||
* - form text (a=1&b=2) and other raw text
|
||||
*
|
||||
* Credentials found in headers or query strings are never kept: the value is replaced by {{secret}} and the row is
|
||||
* flagged as secret, so a key pasted from a sample cannot end up stored.
|
||||
*/
|
||||
|
||||
export interface ImportedRequest {
|
||||
source: string;
|
||||
method?: string;
|
||||
/** The full URL when the file had one. */
|
||||
url?: string;
|
||||
/** Scheme and host of the URL, when present. */
|
||||
baseUrl?: string;
|
||||
path?: string;
|
||||
queryParams: KeyValue[];
|
||||
headers: KeyValue[];
|
||||
contentType?: string;
|
||||
body?: string;
|
||||
/** A response found in the same file (Postman saved response, HAR entry, definition object). */
|
||||
response?: ImportedResponse;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface ImportedResponse {
|
||||
source: string;
|
||||
status?: number;
|
||||
headers: KeyValue[];
|
||||
body?: string;
|
||||
errorBody?: string;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
const SECRET_HEADERS =
|
||||
/^(authorization|proxy-authorization|x-api-key|api-key|apikey|authkey|x-auth-token|auth-token|token|x-access-token|cookie|set-cookie|x-hub-signature(-256)?)$/i;
|
||||
const SECRET_QUERY = /^(key|api[-_]?key|apikey|token|access[-_]?token|auth[-_]?key|secret|password|signature)$/i;
|
||||
const SECRET = '{{secret}}';
|
||||
|
||||
// ── small helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function isObject(v: unknown): v is Record<string, any> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function tryJson(text: string): any {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function prettyJsonOrText(text: string): string {
|
||||
const parsed = tryJson(text);
|
||||
return parsed !== undefined && typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : text;
|
||||
}
|
||||
|
||||
function bodyToText(body: unknown): string | undefined {
|
||||
if (body === undefined || body === null) return undefined;
|
||||
if (typeof body === 'string') return prettyJsonOrText(body);
|
||||
return JSON.stringify(body, null, 2);
|
||||
}
|
||||
|
||||
function toKeyValues(source: unknown, kind: 'header' | 'query' | 'plain'): KeyValue[] {
|
||||
const out: KeyValue[] = [];
|
||||
const add = (key: unknown, value: unknown, description = '', disabled = false) => {
|
||||
const k = String(key ?? '').trim();
|
||||
if (!k) return;
|
||||
const secret = (kind === 'header' && SECRET_HEADERS.test(k)) || (kind === 'query' && SECRET_QUERY.test(k));
|
||||
out.push({
|
||||
key: k,
|
||||
value: secret ? SECRET : value === undefined || value === null ? '' : String(value),
|
||||
description,
|
||||
enabled: !disabled,
|
||||
secret
|
||||
});
|
||||
};
|
||||
if (Array.isArray(source)) {
|
||||
for (const item of source) {
|
||||
if (isObject(item))
|
||||
add(item.key ?? item.name, item.value, String(item.description ?? ''), item.disabled === true);
|
||||
}
|
||||
} else if (isObject(source)) {
|
||||
for (const [
|
||||
k,
|
||||
v
|
||||
] of Object.entries(source)) add(k, v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Splits a URL into its base, path and query without failing on {{placeholders}} or {path} parameters. */
|
||||
function splitUrl(raw: string): { baseUrl?: string; path: string; query: KeyValue[] } {
|
||||
let rest = raw.trim();
|
||||
let baseUrl: string | undefined;
|
||||
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]+)(.*)$/.exec(rest);
|
||||
if (m) {
|
||||
baseUrl = m[1];
|
||||
rest = m[2];
|
||||
}
|
||||
const hash = rest.indexOf('#');
|
||||
if (hash >= 0) rest = rest.slice(0, hash);
|
||||
let queryText = '';
|
||||
const q = rest.indexOf('?');
|
||||
if (q >= 0) {
|
||||
queryText = rest.slice(q + 1);
|
||||
rest = rest.slice(0, q);
|
||||
}
|
||||
const query: KeyValue[] = [];
|
||||
for (const part of queryText.split('&')) {
|
||||
if (!part) continue;
|
||||
const eq = part.indexOf('=');
|
||||
const key = safeDecode(eq >= 0 ? part.slice(0, eq) : part);
|
||||
const value = eq >= 0 ? safeDecode(part.slice(eq + 1)) : '';
|
||||
query.push(...toKeyValues([{ key, value }], 'query'));
|
||||
}
|
||||
return { baseUrl, path: rest || '/', query };
|
||||
}
|
||||
|
||||
function safeDecode(s: string): string {
|
||||
try {
|
||||
return decodeURIComponent(s.replace(/\+/g, ' '));
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function detectContentType(
|
||||
headers: KeyValue[],
|
||||
body: string | undefined
|
||||
): { contentType?: string; headers: KeyValue[] } {
|
||||
const ct = headers.find((h) => h.key.toLowerCase() === 'content-type');
|
||||
const rest = headers.filter((h) => h.key.toLowerCase() !== 'content-type');
|
||||
if (ct) return { contentType: ct.value.split(';')[0].trim(), headers: rest };
|
||||
if (!body) return { headers: rest };
|
||||
if (tryJson(body) !== undefined) return { contentType: 'application/json', headers: rest };
|
||||
if (/^\s*</.test(body)) return { contentType: 'application/xml', headers: rest };
|
||||
if (/^[^\s=&]+=[^&]*(&[^\s=&]+=[^&]*)*$/.test(body.trim()))
|
||||
return { contentType: 'application/x-www-form-urlencoded', headers: rest };
|
||||
return { contentType: 'text/plain', headers: rest };
|
||||
}
|
||||
|
||||
/** Everything about a request is derived the same way once method / url / headers / body are known. */
|
||||
function finishRequest(
|
||||
source: string,
|
||||
method: string | undefined,
|
||||
url: string | undefined,
|
||||
pathOnly: string | undefined,
|
||||
query: unknown,
|
||||
headers: KeyValue[],
|
||||
body: string | undefined,
|
||||
contentTypeHint: string | undefined,
|
||||
notes: string[]
|
||||
): ImportedRequest {
|
||||
const parts = url ? splitUrl(url) : pathOnly ? splitUrl(pathOnly) : undefined;
|
||||
const queryParams = [
|
||||
...(parts?.query ?? []),
|
||||
...toKeyValues(query, 'query')
|
||||
];
|
||||
const detected = detectContentType(headers, body);
|
||||
if (queryParams.some((q) => q.secret))
|
||||
notes.push('A credential in the query string was replaced by {{secret}} - set it on the Connection tab.');
|
||||
if (headers.some((h) => h.secret))
|
||||
notes.push('Credentials in the headers were replaced by {{secret}} - set them on the Connection tab.');
|
||||
return {
|
||||
source,
|
||||
method: method ? method.toUpperCase() : body ? 'POST' : undefined,
|
||||
url,
|
||||
baseUrl: parts?.baseUrl,
|
||||
path: parts?.path,
|
||||
queryParams,
|
||||
headers: detected.headers,
|
||||
contentType: contentTypeHint ?? detected.contentType,
|
||||
body,
|
||||
notes
|
||||
};
|
||||
}
|
||||
|
||||
// ── cURL ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Splits a shell command into words, honouring '…' "…" quotes and \ line continuations. */
|
||||
function shellWords(command: string): string[] {
|
||||
const words: string[] = [];
|
||||
let cur = '';
|
||||
let quote: '"' | "'" | null = null;
|
||||
let started = false;
|
||||
const text = command.replace(/\\\r?\n/g, ' ').trim();
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (quote) {
|
||||
if (c === quote) quote = null;
|
||||
else if (c === '\\' && quote === '"' && i + 1 < text.length) cur += text[++i];
|
||||
else cur += c;
|
||||
} else if (c === '"' || c === "'") {
|
||||
quote = c;
|
||||
started = true;
|
||||
} else if (/\s/.test(c)) {
|
||||
if (started || cur) words.push(cur);
|
||||
cur = '';
|
||||
started = false;
|
||||
} else if (c === '\\' && i + 1 < text.length) {
|
||||
cur += text[++i];
|
||||
} else {
|
||||
cur += c;
|
||||
}
|
||||
}
|
||||
if (started || cur) words.push(cur);
|
||||
return words;
|
||||
}
|
||||
|
||||
function parseCurl(text: string): ImportedRequest {
|
||||
const words = shellWords(text);
|
||||
const notes: string[] = [];
|
||||
let method: string | undefined;
|
||||
let url: string | undefined;
|
||||
const headers: KeyValue[] = [];
|
||||
const data: string[] = [];
|
||||
let form = false;
|
||||
let getMode = false;
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const w = words[i];
|
||||
const next = () => words[++i] ?? '';
|
||||
if (w === 'curl') continue;
|
||||
if (w === '-X' || w === '--request') method = next();
|
||||
else if (w === '-H' || w === '--header') {
|
||||
const h = next();
|
||||
const c = h.indexOf(':');
|
||||
if (c > 0) headers.push(...toKeyValues([{ key: h.slice(0, c), value: h.slice(c + 1).trim() }], 'header'));
|
||||
} else if ([
|
||||
'-d',
|
||||
'--data',
|
||||
'--data-raw',
|
||||
'--data-binary',
|
||||
'--data-ascii'
|
||||
].includes(w)) data.push(next());
|
||||
else if (w === '--data-urlencode') {
|
||||
data.push(next());
|
||||
form = true;
|
||||
} else if (w === '-F' || w === '--form') {
|
||||
data.push(next());
|
||||
form = true;
|
||||
notes.push('Multipart form fields were read as name=value pairs.');
|
||||
} else if (w === '--url') url = next();
|
||||
else if (w === '-G' || w === '--get') getMode = true;
|
||||
else if (w === '-u' || w === '--user') {
|
||||
next();
|
||||
headers.push({
|
||||
key: 'Authorization',
|
||||
value: SECRET,
|
||||
description: 'Basic authentication from the cURL -u option',
|
||||
enabled: true,
|
||||
secret: true
|
||||
});
|
||||
} else if (w.startsWith('-')) {
|
||||
// Flags that take a value we do not need.
|
||||
if ([
|
||||
'-o',
|
||||
'--output',
|
||||
'-A',
|
||||
'--user-agent',
|
||||
'-e',
|
||||
'--referer',
|
||||
'--connect-timeout',
|
||||
'-m',
|
||||
'--max-time',
|
||||
'--proxy',
|
||||
'-x'
|
||||
].includes(w)) next();
|
||||
} else if (!url && /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(w)) url = w;
|
||||
else if (!url && /^[\w.-]+(:\d+)?\//.test(w)) url = w;
|
||||
}
|
||||
let body = data.length ? data.join('&') : undefined;
|
||||
if (body && getMode) {
|
||||
url = url + (url?.includes('?') ? '&' : '?') + body;
|
||||
body = undefined;
|
||||
}
|
||||
return finishRequest(
|
||||
'cURL command',
|
||||
method ?? (getMode ? 'GET' : body ? 'POST' : 'GET'),
|
||||
url,
|
||||
undefined,
|
||||
undefined,
|
||||
headers,
|
||||
body ? prettyJsonOrText(body) : undefined,
|
||||
form && !headers.some((h) => h.key.toLowerCase() === 'content-type')
|
||||
? 'application/x-www-form-urlencoded'
|
||||
: undefined,
|
||||
notes
|
||||
);
|
||||
}
|
||||
|
||||
// ── Postman / HAR / definition object ───────────────────────────────────────
|
||||
|
||||
function findPostmanItem(node: any): any {
|
||||
if (!node) return undefined;
|
||||
if (node.request) return node;
|
||||
if (Array.isArray(node.item)) {
|
||||
for (const child of node.item) {
|
||||
const found = findPostmanItem(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function postmanUrl(url: any): string | undefined {
|
||||
if (!url) return undefined;
|
||||
if (typeof url === 'string') return url;
|
||||
if (typeof url.raw === 'string') return url.raw;
|
||||
const host = Array.isArray(url.host) ? url.host.join('.') : url.host;
|
||||
const path = Array.isArray(url.path) ? url.path.join('/') : url.path;
|
||||
return `${url.protocol ? url.protocol + '://' : ''}${host ?? ''}/${path ?? ''}`;
|
||||
}
|
||||
|
||||
function postmanResponse(r: any): ImportedResponse {
|
||||
return {
|
||||
source: 'Postman saved response',
|
||||
status: typeof r?.code === 'number' ? r.code : undefined,
|
||||
headers: toKeyValues(r?.header, 'header'),
|
||||
body: bodyToText(r?.body),
|
||||
notes: []
|
||||
};
|
||||
}
|
||||
|
||||
function fromPostman(item: any): ImportedRequest {
|
||||
const req = item.request;
|
||||
const notes: string[] = [];
|
||||
let body: string | undefined;
|
||||
let contentType: string | undefined;
|
||||
const b = req?.body;
|
||||
if (b?.mode === 'raw') {
|
||||
body = bodyToText(b.raw);
|
||||
const lang = b.options?.raw?.language;
|
||||
if (lang === 'json') contentType = 'application/json';
|
||||
} else if (b?.mode === 'urlencoded') {
|
||||
body = (b.urlencoded ?? [])
|
||||
.filter((p: any) => !p.disabled)
|
||||
.map((p: any) => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value ?? '')}`)
|
||||
.join('&');
|
||||
contentType = 'application/x-www-form-urlencoded';
|
||||
} else if (b?.mode === 'formdata') {
|
||||
body = (b.formdata ?? []).map((p: any) => `${p.key}=${p.value ?? ''}`).join('&');
|
||||
contentType = 'multipart/form-data';
|
||||
}
|
||||
const request = finishRequest(
|
||||
'Postman request',
|
||||
typeof req?.method === 'string' ? req.method : undefined,
|
||||
postmanUrl(req?.url),
|
||||
undefined,
|
||||
undefined,
|
||||
toKeyValues(req?.header, 'header'),
|
||||
body,
|
||||
contentType,
|
||||
notes
|
||||
);
|
||||
if (Array.isArray(item.response) && item.response.length) request.response = postmanResponse(item.response[0]);
|
||||
if (req?.auth)
|
||||
notes.push('The request had an auth section; it was not read. Set authentication on the Connection tab.');
|
||||
return request;
|
||||
}
|
||||
|
||||
function fromHar(har: any): ImportedRequest {
|
||||
const entry = har.log.entries[0];
|
||||
const r = entry.request ?? {};
|
||||
const body = r.postData?.text ? prettyJsonOrText(r.postData.text) : undefined;
|
||||
const request = finishRequest(
|
||||
'HAR capture',
|
||||
r.method,
|
||||
r.url,
|
||||
undefined,
|
||||
undefined,
|
||||
toKeyValues(r.headers, 'header'),
|
||||
body,
|
||||
typeof r.postData?.mimeType === 'string' ? r.postData.mimeType.split(';')[0] : undefined,
|
||||
[]
|
||||
);
|
||||
const res = entry.response;
|
||||
if (res) {
|
||||
request.response = {
|
||||
source: 'HAR capture',
|
||||
status: typeof res.status === 'number' ? res.status : undefined,
|
||||
headers: toKeyValues(res.headers, 'header'),
|
||||
body: res.content?.text ? prettyJsonOrText(res.content.text) : undefined,
|
||||
notes: []
|
||||
};
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
function definitionResponse(def: any): ImportedResponse | undefined {
|
||||
const r = def.response ?? def.responseDefinition;
|
||||
const status = def.status ?? def.statusCode ?? def.successCode ?? r?.status ?? r?.statusCode ?? r?.code;
|
||||
const headers = def.responseHeaders ?? r?.headers;
|
||||
const body = def.responseBody ?? (isObject(r) ? (r.body ?? r.data) : r);
|
||||
const errorBody = def.errorBody ?? def.errorResponse ?? r?.errorBody;
|
||||
if (status === undefined && headers === undefined && body === undefined && errorBody === undefined) return undefined;
|
||||
return {
|
||||
source: 'API definition',
|
||||
status: typeof status === 'number' ? status : status ? Number(status) || undefined : undefined,
|
||||
headers: toKeyValues(headers, 'header'),
|
||||
body: bodyToText(body),
|
||||
errorBody: bodyToText(errorBody),
|
||||
notes: []
|
||||
};
|
||||
}
|
||||
|
||||
function fromDefinition(def: any): ImportedRequest {
|
||||
const body = def.body ?? def.payload ?? def.data ?? def.requestBody ?? def.request;
|
||||
const bodyText = isObject(body) && ('method' in body || 'url' in body) ? undefined : bodyToText(body);
|
||||
const url = def.url ?? def.endpoint ?? def.uri;
|
||||
const request = finishRequest(
|
||||
'API definition',
|
||||
def.method ?? def.httpMethod,
|
||||
typeof url === 'string' ? url : undefined,
|
||||
typeof def.path === 'string' ? def.path : undefined,
|
||||
def.query ?? def.queryParams ?? def.params,
|
||||
toKeyValues(def.headers, 'header'),
|
||||
bodyText,
|
||||
typeof def.contentType === 'string' ? def.contentType : undefined,
|
||||
[]
|
||||
);
|
||||
request.response = definitionResponse(def);
|
||||
return request;
|
||||
}
|
||||
|
||||
// ── public: request side ────────────────────────────────────────────────────
|
||||
|
||||
export function parseRequestText(text: string): ImportedRequest {
|
||||
const t = (text ?? '').trim();
|
||||
if (!t) throw new Error('Nothing to read - paste the request or choose a file.');
|
||||
if (/^curl\b/i.test(t)) return parseCurl(t);
|
||||
|
||||
const parsed = tryJson(t);
|
||||
if (parsed === undefined) {
|
||||
// Not JSON: a cURL command in the middle of text, form data, or other raw text.
|
||||
const curl = /curl\b[\s\S]*/i.exec(t);
|
||||
if (curl && /https?:\/\//.test(curl[0])) return parseCurl(curl[0]);
|
||||
const detected = detectContentType([], t);
|
||||
return finishRequest('Raw text', undefined, undefined, undefined, undefined, [], t, detected.contentType, [
|
||||
'The text is not JSON; it was kept as the request payload.'
|
||||
]);
|
||||
}
|
||||
if (isObject(parsed) && isObject(parsed.log) && Array.isArray(parsed.log.entries) && parsed.log.entries.length)
|
||||
return fromHar(parsed);
|
||||
if (isObject(parsed) && (parsed.item || parsed.info?.schema)) {
|
||||
const item = findPostmanItem(parsed);
|
||||
if (!item) throw new Error('The Postman collection has no request in it.');
|
||||
return fromPostman(item);
|
||||
}
|
||||
if (
|
||||
isObject(parsed) &&
|
||||
isObject(parsed.request) &&
|
||||
(parsed.request.url || parsed.request.method || parsed.request.header)
|
||||
)
|
||||
return fromPostman(parsed);
|
||||
if (isObject(parsed) && [
|
||||
'method',
|
||||
'httpMethod',
|
||||
'url',
|
||||
'endpoint',
|
||||
'uri',
|
||||
'headers',
|
||||
'queryParams',
|
||||
'requestBody',
|
||||
'payload'
|
||||
].some((k) => k in parsed) && !('statuses' in parsed)) {
|
||||
return fromDefinition(parsed);
|
||||
}
|
||||
// A bare JSON payload.
|
||||
return finishRequest(
|
||||
'JSON payload',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
JSON.stringify(parsed, null, 2),
|
||||
'application/json',
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
// ── public: response side ───────────────────────────────────────────────────
|
||||
|
||||
export function parseResponseText(text: string): ImportedResponse {
|
||||
const t = (text ?? '').trim();
|
||||
if (!t) throw new Error('Nothing to read - paste the response or choose a file.');
|
||||
const parsed = tryJson(t);
|
||||
if (parsed === undefined) {
|
||||
return {
|
||||
source: 'Raw text',
|
||||
headers: [],
|
||||
body: t,
|
||||
notes: ['The text is not JSON; it was kept as the response body.']
|
||||
};
|
||||
}
|
||||
if (isObject(parsed) && isObject(parsed.log) && Array.isArray(parsed.log.entries) && parsed.log.entries.length) {
|
||||
return (
|
||||
fromHar(parsed).response ?? { source: 'HAR capture', headers: [], notes: ['The HAR entry has no response.'] }
|
||||
);
|
||||
}
|
||||
if (isObject(parsed) && (parsed.item || parsed.info?.schema)) {
|
||||
const item = findPostmanItem(parsed);
|
||||
const res = item ? fromPostman(item).response : undefined;
|
||||
if (!res) throw new Error('The Postman collection has no saved response.');
|
||||
return res;
|
||||
}
|
||||
if (isObject(parsed) && Array.isArray(parsed.response) && parsed.response.length && (parsed.request || parsed.name)) {
|
||||
return postmanResponse(parsed.response[0]);
|
||||
}
|
||||
if (isObject(parsed) && [
|
||||
'status',
|
||||
'statusCode',
|
||||
'headers',
|
||||
'responseHeaders',
|
||||
'responseBody',
|
||||
'errorBody'
|
||||
].some(
|
||||
(k) => k in parsed
|
||||
) && ('body' in parsed || 'responseBody' in parsed || 'headers' in parsed || 'responseHeaders' in parsed || 'errorBody' in parsed || 'data' in parsed)) {
|
||||
const r = definitionResponse({
|
||||
status: parsed.status ?? parsed.statusCode,
|
||||
responseHeaders: parsed.responseHeaders ?? parsed.headers,
|
||||
responseBody: parsed.responseBody ?? parsed.body ?? parsed.data,
|
||||
errorBody: parsed.errorBody
|
||||
});
|
||||
if (r) return r;
|
||||
}
|
||||
return { source: 'JSON body', headers: [], body: JSON.stringify(parsed, null, 2), notes: [] };
|
||||
}
|
||||
|
||||
// ── JSON → mapping rows ─────────────────────────────────────────────────────
|
||||
|
||||
export interface JsonLeaf {
|
||||
path: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
/** Every value in a JSON document with its path: `a.b[0].c`. Only the first item of an array is walked. */
|
||||
export function flatten(json: unknown, prefix = ''): JsonLeaf[] {
|
||||
const out: JsonLeaf[] = [];
|
||||
if (Array.isArray(json)) {
|
||||
if (json.length) out.push(...flatten(json[0], `${prefix}[0]`));
|
||||
} else if (isObject(json)) {
|
||||
for (const [
|
||||
k,
|
||||
v
|
||||
] of Object.entries(json)) {
|
||||
out.push(...flatten(v, prefix ? `${prefix}.${k}` : k));
|
||||
}
|
||||
} else if (prefix) {
|
||||
out.push({ path: prefix, value: json });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function lastKey(path: string): string {
|
||||
const m = /([A-Za-z0-9_$-]+)(\[\d+\])*$/.exec(path);
|
||||
return m ? m[1] : path;
|
||||
}
|
||||
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
const REQUEST_GUESS: [RegExp, string, boolean][] = [
|
||||
[
|
||||
/^(mobile|mobiles|mobileno|mobilenumber|msisdn|phone|phoneno|phonenumber|to|destination|recipient|recipients|number|contact)$/,
|
||||
'customer.mobile',
|
||||
true
|
||||
],
|
||||
[
|
||||
/^(templateid|template|templatename|dlttemplateid|flowid)$/,
|
||||
'message.templateId',
|
||||
true
|
||||
],
|
||||
[
|
||||
/^(message|text|body|content|sms|msg|smstext|var1|messagetext)$/,
|
||||
'message.text',
|
||||
true
|
||||
],
|
||||
[
|
||||
/^(sender|senderid|from|source|shortcode)$/,
|
||||
'bank.senderId',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(email|emailid|mail|emailaddress)$/,
|
||||
'customer.email',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(customername|fullname|firstname|name)$/,
|
||||
'customer.name',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(otp|code|pin)$/,
|
||||
'message.otp',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(pan|pannumber)$/,
|
||||
'customer.pan',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(aadhaar|aadhar|aadhaarnumber)$/,
|
||||
'customer.aadhaar',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(dob|dateofbirth|birthdate)$/,
|
||||
'customer.dob',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(membercode|memberid|memberreference)$/,
|
||||
'bank.memberCode',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(accountno|accountnumber|acno|account)$/,
|
||||
'account.number',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(cif|customerid|clientid|cifno)$/,
|
||||
'customer.cif',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(amount|amt|txnamount|transactionamount)$/,
|
||||
'txn.amount',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(txnid|transactionid|transactionreference|txnref)$/,
|
||||
'txn.reference',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(txndate|transactiondate)$/,
|
||||
'txn.date',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(fromdate|startdate|datefrom)$/,
|
||||
'request.fromDate',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(todate|enddate|dateto)$/,
|
||||
'request.toDate',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(reference|referenceno|referencenumber|ref|requestid|clientreference|correlationid)$/,
|
||||
'request.reference',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(tenantidentifier|tenantid)$/,
|
||||
'tenant.identifier',
|
||||
false
|
||||
],
|
||||
[
|
||||
/^(providerid)$/,
|
||||
'sms.providerId',
|
||||
false
|
||||
]
|
||||
];
|
||||
|
||||
function guessTransform(cbsField: string, value: unknown): string {
|
||||
if (cbsField === 'customer.mobile') return 'DIGITS_ONLY';
|
||||
if (cbsField === 'customer.email') return 'LOWER';
|
||||
if (cbsField === 'bank.senderId') return 'UPPER';
|
||||
if (cbsField === 'txn.amount') return 'NUMBER';
|
||||
if (typeof value === 'boolean') return 'BOOLEAN';
|
||||
if (typeof value === 'number') return 'NUMBER';
|
||||
if (typeof value === 'string') {
|
||||
if (/^\d{8}$/.test(value) && cbsField.includes('Date')) return 'DATE_DDMMYYYY';
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return 'DATE_YYYY_MM_DD';
|
||||
}
|
||||
return 'NONE';
|
||||
}
|
||||
|
||||
/**
|
||||
* A first mapping for a request payload: each value that looks like a known CBS field (mobile, message, template,
|
||||
* sender ...) becomes "CBS field → path"; every other value becomes a fixed value at its path, so the whole payload is
|
||||
* reproduced. Review and adjust.
|
||||
*/
|
||||
export function guessRequestRows(bodyText: string): ApiMappingRow[] {
|
||||
const parsed = tryJson(bodyText);
|
||||
if (parsed === undefined || typeof parsed !== 'object' || parsed === null) return [];
|
||||
const rows: ApiMappingRow[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const leaf of flatten(parsed)) {
|
||||
const key = norm(lastKey(leaf.path));
|
||||
const hit = REQUEST_GUESS.find(([re]) => re.test(key));
|
||||
if (hit && !used.has(hit[1])) {
|
||||
used.add(hit[1]);
|
||||
rows.push({
|
||||
from: hit[1],
|
||||
to: leaf.path,
|
||||
transform: guessTransform(hit[1], leaf.value),
|
||||
defaultValue: '',
|
||||
mandatory: hit[2]
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
from: '',
|
||||
to: leaf.path,
|
||||
transform: guessTransform('', leaf.value),
|
||||
defaultValue: leaf.value === null ? '' : String(leaf.value),
|
||||
mandatory: false
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows.filter((r) => r.from || r.defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* A first mapping for a response body: every value at its path → a CBS field named after the vendor and the key
|
||||
* (`sms.messageId`). Ids / references are marked required.
|
||||
*/
|
||||
export function guessResponseRows(bodyText: string, vendorCode: string): ApiMappingRow[] {
|
||||
const parsed = tryJson(bodyText);
|
||||
if (parsed === undefined || typeof parsed !== 'object' || parsed === null) return [];
|
||||
const prefix = vendorCode.toLowerCase().split('_')[0] || 'vendor';
|
||||
const rows: ApiMappingRow[] = [];
|
||||
const used = new Set<string>();
|
||||
for (const leaf of flatten(parsed)) {
|
||||
const key = lastKey(leaf.path);
|
||||
const n = norm(key);
|
||||
let target: string;
|
||||
if (/^(messageid|msgid|id|reference|requestid|referenceid|messageid)$/.test(n)) target = 'reference';
|
||||
else if (/^(status|state|result|type)$/.test(n)) target = 'status';
|
||||
else if (/^(error|errormessage|errormsg|reason|detail|description)$/.test(n)) target = 'error';
|
||||
else target = key.replace(/[^A-Za-z0-9]+(.)?/g, (_m, c: string) => (c ? c.toUpperCase() : ''));
|
||||
let to = `${prefix}.${target}`;
|
||||
for (let i = 2; used.has(to); i++) to = `${prefix}.${target}${i}`;
|
||||
used.add(to);
|
||||
rows.push({
|
||||
from: leaf.path,
|
||||
to,
|
||||
transform: target === 'status' ? 'UPPER' : guessTransform('', leaf.value),
|
||||
defaultValue: '',
|
||||
mandatory: target === 'reference'
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function secretNote(rows: KeyValue[]): string | null {
|
||||
const n = rows.filter((r) => r.secret).length;
|
||||
return n ? `${n} credential value(s) were hidden.` : null;
|
||||
}
|
||||
|
||||
export { kv };
|
||||
@@ -0,0 +1,61 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
@if (!rows.length) {
|
||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">{{ emptyText }}</div>
|
||||
} @else {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 34px">On</th>
|
||||
<th>{{ keyLabel }}</th>
|
||||
<th>{{ valueLabel }}</th>
|
||||
<th>Note</th>
|
||||
<th style="width: 60px">Secret</th>
|
||||
@if (!fixedKeys) {
|
||||
<th style="width: 40px"></th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of rows; track $index; let i = $index) {
|
||||
<tr>
|
||||
<td style="text-align: center">
|
||||
<input type="checkbox" [(ngModel)]="r.enabled" style="width: auto" />
|
||||
</td>
|
||||
<td>
|
||||
<input class="cic-mono" [(ngModel)]="r.key" [placeholder]="keyPlaceholder" [readonly]="fixedKeys" />
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
class="cic-mono"
|
||||
[type]="r.secret ? 'password' : 'text'"
|
||||
[(ngModel)]="r.value"
|
||||
[placeholder]="valuePlaceholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</td>
|
||||
<td><input [(ngModel)]="r.description" /></td>
|
||||
<td style="text-align: center">
|
||||
<input type="checkbox" [(ngModel)]="r.secret" style="width: auto" title="Show the value masked" />
|
||||
</td>
|
||||
@if (!fixedKeys) {
|
||||
<td>
|
||||
<button class="cic-btn sm danger" (click)="remove(i)">✕</button>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (!fixedKeys) {
|
||||
<button class="cic-btn sm" style="margin-top: 8px" (click)="add()">{{ addLabel }}</button>
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
|
||||
import { KeyValue } from '../api-management.service';
|
||||
|
||||
/** An editable table of name / value pairs: headers, query parameters, path parameters. Edits the array in place. */
|
||||
@Component({
|
||||
selector: 'mifosx-api-key-value-editor',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './api-key-value-editor.component.html',
|
||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
||||
})
|
||||
export class ApiKeyValueEditorComponent {
|
||||
@Input() rows: KeyValue[] = [];
|
||||
@Input() keyLabel = 'Name';
|
||||
@Input() valueLabel = 'Value';
|
||||
@Input() keyPlaceholder = '';
|
||||
@Input() valuePlaceholder = '';
|
||||
@Input() addLabel = '+ Add';
|
||||
/** Path parameters come from the path itself; the names cannot be edited or removed. */
|
||||
@Input() fixedKeys = false;
|
||||
@Input() emptyText = 'None.';
|
||||
|
||||
add(): void {
|
||||
this.rows.push({ key: '', value: '', description: '', enabled: true, secret: false });
|
||||
}
|
||||
|
||||
remove(i: number): void {
|
||||
this.rows.splice(i, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
@if (log) {
|
||||
<div class="cic-modal-backdrop" (click)="closed.emit()">
|
||||
<div class="cic-modal" style="width: min(1100px, 100%)" (click)="$event.stopPropagation()">
|
||||
<h3>
|
||||
{{ log.vendorName }} · {{ log.apiName }}
|
||||
<span class="cic-badge" [class.brand]="log.direction === 'IN'" [class.ok]="log.direction === 'OUT'">{{
|
||||
log.direction
|
||||
}}</span>
|
||||
<span class="cic-badge" [class.good]="log.result === 'SUCCESS'" [class.bad]="log.result === 'FAILED'">{{
|
||||
log.result
|
||||
}}</span>
|
||||
</h3>
|
||||
<dl class="cic-kv" style="grid-template-columns: 120px minmax(0, 1fr); margin-bottom: 12px">
|
||||
<dt>Reference</dt>
|
||||
<dd class="cic-mono">{{ log.reference }}</dd>
|
||||
<dt>Call</dt>
|
||||
<dd class="cic-mono">{{ log.method }} {{ log.url }}</dd>
|
||||
<dt>Result</dt>
|
||||
<dd>HTTP {{ log.httpStatus }} in {{ log.durationMs }} ms · {{ log.time | slice: 0 : 16 }}</dd>
|
||||
@if (log.error) {
|
||||
<dt>Error</dt>
|
||||
<dd class="tone-bad">{{ log.error }}</dd>
|
||||
}
|
||||
</dl>
|
||||
<div class="cic-split">
|
||||
<div>
|
||||
<h4 style="margin: 0 0 6px; font-size: 13px">
|
||||
{{ log.direction === 'OUT' ? 'Request headers' : 'Headers received / sent' }}
|
||||
<span class="muted" style="font-weight: 400">(credentials masked)</span>
|
||||
</h4>
|
||||
<pre class="cic-code" style="max-height: 160px">{{ log.requestHeaders }}</pre>
|
||||
<h4 style="margin: 12px 0 6px; font-size: 13px">Request payload</h4>
|
||||
<pre class="cic-code">{{ log.request || '(empty)' }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style="margin: 0 0 6px; font-size: 13px">Response headers</h4>
|
||||
<pre class="cic-code" style="max-height: 160px">{{ log.responseHeaders }}</pre>
|
||||
<h4 style="margin: 12px 0 6px; font-size: 13px">Response body</h4>
|
||||
<pre class="cic-code">{{ log.response || '(empty)' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<button class="cic-btn" (click)="closed.emit()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
import { ApiLog } from '../api-management.service';
|
||||
|
||||
/** One API call: what was sent and what came back. */
|
||||
@Component({
|
||||
selector: 'mifosx-api-log-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './api-log-detail.component.html',
|
||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
||||
})
|
||||
export class ApiLogDetailComponent {
|
||||
@Input() log: ApiLog | null = null;
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cic-page">
|
||||
<div class="cic-head">
|
||||
<div>
|
||||
<h1>API Call Logs</h1>
|
||||
<p>Every API call the CBS made (OUT) or received (IN), across all vendors, with its request and response.</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<a class="cic-btn" routerLink="/api-management/vendors">← All vendors</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cic-card">
|
||||
<div class="cic-grid cols-4" style="align-items: end">
|
||||
<div class="cic-field">
|
||||
<label>Vendor</label>
|
||||
<select [(ngModel)]="filter.vendorId">
|
||||
<option value="">All vendors</option>
|
||||
@for (v of vendors; track v.id) {
|
||||
<option [value]="v.id">{{ v.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Direction</label>
|
||||
<select [(ngModel)]="filter.direction">
|
||||
<option value="">IN and OUT</option>
|
||||
<option value="IN">IN — received</option>
|
||||
<option value="OUT">OUT — sent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Result</label>
|
||||
<select [(ngModel)]="filter.result">
|
||||
<option value="">All</option>
|
||||
<option value="SUCCESS">Success</option>
|
||||
<option value="FAILED">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Search</label>
|
||||
<input [(ngModel)]="filter.search" (keyup.enter)="apply()" placeholder="Reference, API, path" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cic-actions" style="margin-top: 12px; justify-content: flex-end">
|
||||
<button class="cic-btn" (click)="reset()">Reset</button>
|
||||
<button class="cic-btn primary" (click)="apply()">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
{{ total }} call(s)
|
||||
<span class="cic-card-sub">
|
||||
<span class="tone-good">{{ success }} success</span> ·
|
||||
<span [class.tone-bad]="failed > 0">{{ failed }} failed</span>
|
||||
</span>
|
||||
</h2>
|
||||
@if (loading && !items.length) {
|
||||
<div class="cic-empty">Loading…</div>
|
||||
} @else if (!items.length) {
|
||||
<div class="cic-empty">No call matches the filter.</div>
|
||||
} @else {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Vendor</th>
|
||||
<th>API</th>
|
||||
<th>Direction</th>
|
||||
<th>Call</th>
|
||||
<th>HTTP</th>
|
||||
<th class="num">ms</th>
|
||||
<th>Result</th>
|
||||
<th>Reference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of items; track l.id) {
|
||||
<tr class="clickable" (click)="openLog = l">
|
||||
<td>{{ l.time | slice: 0 : 16 }}</td>
|
||||
<td>{{ l.vendorName }}</td>
|
||||
<td>{{ l.apiName }}</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class.brand]="l.direction === 'IN'" [class.ok]="l.direction === 'OUT'">{{
|
||||
l.direction
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="cic-mono" style="font-size: 12px">{{ l.method }} {{ l.path }}</td>
|
||||
<td>{{ l.httpStatus }}</td>
|
||||
<td class="num">{{ l.durationMs }}</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class.good]="l.result === 'SUCCESS'" [class.bad]="l.result === 'FAILED'">{{
|
||||
l.result
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="cic-mono" style="font-size: 12px">{{ l.reference }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cic-pager">
|
||||
<span>{{ offset + 1 }}–{{ offset + items.length }} of {{ total }}</span>
|
||||
<span class="cic-actions">
|
||||
<button class="cic-btn sm" (click)="page(-1)" [disabled]="offset === 0">Previous</button>
|
||||
<button class="cic-btn sm" (click)="page(1)" [disabled]="offset + limit >= total">Next</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<mifosx-api-log-detail [log]="openLog" (closed)="openLog = null"></mifosx-api-log-detail>
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
|
||||
import { ApiLogDetailComponent } from '../api-log-detail/api-log-detail.component';
|
||||
import { ApiLog, ApiManagementService, ApiVendor } from '../api-management.service';
|
||||
|
||||
/** Every API call, IN and OUT, across all vendors, with its request and response. */
|
||||
@Component({
|
||||
selector: 'mifosx-api-logs',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
ApiLogDetailComponent
|
||||
],
|
||||
templateUrl: './api-logs.component.html',
|
||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
||||
})
|
||||
export class ApiLogsComponent implements OnInit {
|
||||
private service = inject(ApiManagementService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
vendors: ApiVendor[] = [];
|
||||
filter = { vendorId: '', direction: '', result: '', search: '' };
|
||||
items: ApiLog[] = [];
|
||||
total = 0;
|
||||
success = 0;
|
||||
failed = 0;
|
||||
offset = 0;
|
||||
readonly limit = 15;
|
||||
loading = false;
|
||||
openLog: ApiLog | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
const vendorId = this.route.snapshot.queryParamMap.get('vendorId');
|
||||
if (vendorId) this.filter.vendorId = vendorId;
|
||||
this.service.vendors().subscribe((v) => (this.vendors = v));
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service.logs({ ...this.filter, offset: this.offset, limit: this.limit }).subscribe((r) => {
|
||||
this.items = r.items;
|
||||
this.total = r.total;
|
||||
this.success = r.success;
|
||||
this.failed = r.failed;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
this.offset = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.filter = { vendorId: '', direction: '', result: '', search: '' };
|
||||
this.apply();
|
||||
}
|
||||
|
||||
page(delta: number): void {
|
||||
this.offset = Math.max(0, this.offset + delta * this.limit);
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { ApiVendorsComponent } from './api-vendors/api-vendors.component';
|
||||
import { ApiVendorSetupComponent } from './api-vendor-setup/api-vendor-setup.component';
|
||||
import { ApiLogsComponent } from './api-logs/api-logs.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'api-management',
|
||||
data: { title: 'API Management', breadcrumb: 'API Management' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'vendors',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'vendors',
|
||||
data: { breadcrumb: 'Vendors' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: ApiVendorsComponent,
|
||||
data: { title: 'API Vendors & Setup' }
|
||||
},
|
||||
{
|
||||
path: ':id',
|
||||
component: ApiVendorSetupComponent,
|
||||
data: { title: 'Vendor Setup', breadcrumb: 'Setup' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
component: ApiLogsComponent,
|
||||
data: { title: 'API Call Logs', breadcrumb: 'Call Logs' }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class ApiManagementRoutingModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { ApiManagementRoutingModule } from './api-management-routing.module';
|
||||
import { ApiVendorsComponent } from './api-vendors/api-vendors.component';
|
||||
import { ApiVendorSetupComponent } from './api-vendor-setup/api-vendor-setup.component';
|
||||
import { ApiLogsComponent } from './api-logs/api-logs.component';
|
||||
import { ApiLogDetailComponent } from './api-log-detail/api-log-detail.component';
|
||||
|
||||
/** API Management: connect any outside API service (CIC, SMS, WhatsApp ...) - vendor, work, API set, IN / OUT. */
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
ApiManagementRoutingModule,
|
||||
ApiVendorsComponent,
|
||||
ApiVendorSetupComponent,
|
||||
ApiLogsComponent,
|
||||
ApiLogDetailComponent
|
||||
]
|
||||
})
|
||||
export class ApiManagementModule {}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,777 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cic-page">
|
||||
<div class="cic-head">
|
||||
<div>
|
||||
<h1>{{ vendor?.name || 'Vendor setup' }}</h1>
|
||||
<p>{{ vendor?.work }}</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
@if (vendor) {
|
||||
<span class="cic-badge" [class.good]="vendor.active">{{ vendor.active ? 'Active' : 'Switched off' }}</span>
|
||||
<span
|
||||
class="cic-badge"
|
||||
[class.warn]="vendor.environment === 'SIMULATOR'"
|
||||
[class.good]="vendor.environment === 'PRODUCTION'"
|
||||
>{{ vendor.environment }}</span
|
||||
>
|
||||
}
|
||||
<a class="cic-btn" routerLink="/api-management/vendors">← All vendors</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error && !editing && !importOpen) {
|
||||
<div class="cic-alert error">{{ error }}</div>
|
||||
}
|
||||
@if (info && !editing) {
|
||||
<div class="cic-alert success">{{ info }}</div>
|
||||
}
|
||||
@if (loading && !vendor) {
|
||||
<div class="cic-card cic-empty">Loading…</div>
|
||||
}
|
||||
|
||||
@if (vendor) {
|
||||
@if (vendor.configNote) {
|
||||
<div class="cic-alert info">{{ vendor.configNote }}</div>
|
||||
}
|
||||
@if (vendor.links.length) {
|
||||
<div class="cic-alert info">
|
||||
{{ vendor.code }} also has its own screens:
|
||||
@for (l of vendor.links; track l.route) {
|
||||
<a class="cic-btn sm" style="margin-left: 6px" [routerLink]="l.route">{{ l.label }}</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cic-tabs">
|
||||
<button [class.active]="tab === 'connection'" (click)="tab = 'connection'">1 · Connection</button>
|
||||
<button [class.active]="tab === 'apis'" (click)="tab = 'apis'">2 · API set ({{ vendor.apis.length }})</button>
|
||||
<button [class.active]="tab === 'test'" (click)="tab = 'test'">3 · Test</button>
|
||||
<button [class.active]="tab === 'calls'" (click)="tab = 'calls'; loadCalls()">Recent calls</button>
|
||||
</div>
|
||||
|
||||
<!-- CONNECTION -->
|
||||
@if (tab === 'connection') {
|
||||
<div class="cic-card">
|
||||
<h2>Vendor and connection</h2>
|
||||
<div class="cic-grid cols-2">
|
||||
<div class="cic-field">
|
||||
<label>Vendor name</label>
|
||||
<input [(ngModel)]="form.name" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Environment</label>
|
||||
<select [(ngModel)]="form.environment">
|
||||
<option value="SIMULATOR">SIMULATOR — no network call</option>
|
||||
<option value="UAT">UAT — vendor's test environment</option>
|
||||
<option value="PRODUCTION">PRODUCTION — live</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-field" style="grid-column: 1 / -1">
|
||||
<label>Work — what this vendor is used for</label>
|
||||
<input [(ngModel)]="form.work" />
|
||||
</div>
|
||||
<div class="cic-field" style="grid-column: 1 / -1">
|
||||
<label>Base URL</label>
|
||||
<input class="cic-mono" [(ngModel)]="form.baseUrl" placeholder="https://api.vendor.example/v1" />
|
||||
<span class="hint">Every OUT / pull API path is added to this. PRODUCTION needs https://.</span>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Authentication</label>
|
||||
<select [(ngModel)]="form.authType">
|
||||
@for (a of authTypes; track a.key) {
|
||||
<option [value]="a.key">{{ a.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (form.authType === 'BASIC') {
|
||||
<div class="cic-field">
|
||||
<label>User id</label>
|
||||
<input [(ngModel)]="form.username" autocomplete="off" />
|
||||
</div>
|
||||
}
|
||||
@if (form.authType === 'API_KEY') {
|
||||
<div class="cic-field">
|
||||
<label>API key header name</label>
|
||||
<input class="cic-mono" [(ngModel)]="form.apiKeyHeader" />
|
||||
</div>
|
||||
}
|
||||
@if (needsSecret()) {
|
||||
<div class="cic-field">
|
||||
<label>{{ secretLabel() }}</label>
|
||||
<input
|
||||
type="password"
|
||||
[(ngModel)]="form.secret"
|
||||
autocomplete="new-password"
|
||||
[placeholder]="vendor.hasSecret ? '•••••• stored — leave blank to keep' : 'Enter to store (encrypted)'"
|
||||
/>
|
||||
@if (vendor.hasSecret) {
|
||||
<label style="display: flex; gap: 6px; align-items: center; font-weight: 500; font-size: 12px">
|
||||
<input type="checkbox" [(ngModel)]="form.clearSecret" style="width: auto" /> Remove the stored
|
||||
credential
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="cic-field">
|
||||
<label>Sender / member id</label>
|
||||
<input [(ngModel)]="form.senderId" />
|
||||
<span class="hint"
|
||||
>The bank's id at this vendor (SMS sender id, WhatsApp number id, CIC member code …).</span
|
||||
>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Timeout (ms)</label>
|
||||
<input type="number" min="1000" max="300000" [(ngModel)]="form.timeoutMs" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Retries on failure</label>
|
||||
<input type="number" min="0" max="5" [(ngModel)]="form.retries" />
|
||||
</div>
|
||||
<label style="display: flex; gap: 8px; align-items: center; font-size: 13px">
|
||||
<input type="checkbox" [(ngModel)]="form.active" /> Vendor active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h4 style="margin: 18px 0 6px; font-size: 13px">
|
||||
Default headers
|
||||
<span class="muted" style="font-weight: 400"
|
||||
>— sent on every API of this vendor (an API can override one)</span
|
||||
>
|
||||
</h4>
|
||||
<mifosx-api-key-value-editor
|
||||
[rows]="form.defaultHeaders"
|
||||
keyLabel="Header"
|
||||
keyPlaceholder="Accept"
|
||||
addLabel="+ Add header"
|
||||
emptyText="No default header."
|
||||
></mifosx-api-key-value-editor>
|
||||
|
||||
<div class="cic-actions" style="margin-top: 16px">
|
||||
<button class="cic-btn primary" (click)="saveConnection()" [disabled]="saving">
|
||||
{{ saving ? 'Saving…' : 'Save connection' }}
|
||||
</button>
|
||||
<button class="cic-btn" (click)="tab = 'apis'">Next: API set →</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- API SET -->
|
||||
@if (tab === 'apis') {
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
API set
|
||||
<span class="cic-actions">
|
||||
<button class="cic-btn sm" (click)="newApi('OUT')">+ OUT API (CBS calls vendor)</button>
|
||||
<button class="cic-btn sm" (click)="newApi('IN')">+ IN API (data comes into CBS)</button>
|
||||
</span>
|
||||
</h2>
|
||||
@if (!vendor.apis.length) {
|
||||
<div class="cic-empty">
|
||||
No API yet. Add one, then fill it in by hand or read it from a JSON / cURL / Postman file.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>API</th>
|
||||
<th>Direction</th>
|
||||
<th>Call</th>
|
||||
<th>Used for</th>
|
||||
<th>Headers / query</th>
|
||||
<th>Mapping</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of vendor.apis; track a.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<b>{{ a.name }}</b>
|
||||
</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class.brand]="a.direction === 'IN'" [class.ok]="a.direction === 'OUT'">{{
|
||||
a.direction
|
||||
}}</span>
|
||||
@if (a.mode) {
|
||||
<span class="muted" style="font-size: 11px"> {{ a.mode === 'PULL' ? 'pull' : 'push' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td class="cic-mono" style="font-size: 12px">{{ a.method }} {{ a.path }}</td>
|
||||
<td style="max-width: 260px; white-space: normal">{{ a.usedFor }}</td>
|
||||
<td>{{ a.headers.length }} / {{ a.queryParams.length }}</td>
|
||||
<td>{{ a.requestRows.length }} / {{ a.responseRows.length }}</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class.good]="a.active">{{ a.active ? 'Yes' : 'Off' }}</span>
|
||||
</td>
|
||||
<td style="white-space: nowrap">
|
||||
<button class="cic-btn sm" (click)="editApi(a)">Edit</button>
|
||||
<button class="cic-btn sm danger" (click)="deleteApi(a)">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TEST -->
|
||||
@if (tab === 'test') {
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
Test an API
|
||||
<span class="cic-card-sub">simulated — nothing is sent to the vendor; the result is added to the logs</span>
|
||||
</h2>
|
||||
@if (!vendor.apis.length) {
|
||||
<div class="cic-empty">Add an API first.</div>
|
||||
} @else {
|
||||
<div class="cic-field" style="max-width: 420px">
|
||||
<label>API</label>
|
||||
<select [(ngModel)]="testApiId" (ngModelChange)="fillTestBody()">
|
||||
@for (a of vendor.apis; track a.id) {
|
||||
<option [ngValue]="a.id">{{ a.direction }} · {{ a.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (testPreview; as p) {
|
||||
<h4 style="margin: 14px 0 6px; font-size: 13px">The request</h4>
|
||||
<pre class="cic-code" style="max-height: 200px">{{ p.method }} {{ p.url }}
|
||||
@for (h of p.headers; track h.key) {
|
||||
{{ h.key }}: {{ h.value }}
|
||||
}</pre>
|
||||
}
|
||||
<div class="cic-field" style="margin-top: 12px">
|
||||
<label>Request body</label>
|
||||
<textarea class="cic-mono" rows="9" [(ngModel)]="testBody"></textarea>
|
||||
</div>
|
||||
<button class="cic-btn primary" style="margin-top: 12px" (click)="runTest()" [disabled]="testing">
|
||||
{{ testing ? 'Sending…' : 'Send test' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (testResult) {
|
||||
<div class="cic-alert success">
|
||||
{{ testResult.reference }} — HTTP {{ testResult.httpStatus }} in {{ testResult.durationMs }} ms
|
||||
</div>
|
||||
<div class="cic-split">
|
||||
<div class="cic-card">
|
||||
<h2>Response headers</h2>
|
||||
<pre class="cic-code">{{ testResult.responseHeaders }}</pre>
|
||||
</div>
|
||||
<div class="cic-card">
|
||||
<h2>Response body</h2>
|
||||
<pre class="cic-code">{{ testResult.response || '(empty)' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- RECENT CALLS -->
|
||||
@if (tab === 'calls') {
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
Recent calls
|
||||
<a class="cic-btn sm" routerLink="/api-management/logs" [queryParams]="{ vendorId: vendor.id }">All logs</a>
|
||||
</h2>
|
||||
@if (!calls.length) {
|
||||
<div class="cic-empty">No call yet.</div>
|
||||
} @else {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>API</th>
|
||||
<th>Direction</th>
|
||||
<th>HTTP</th>
|
||||
<th class="num">ms</th>
|
||||
<th>Result</th>
|
||||
<th>Reference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of calls; track l.id) {
|
||||
<tr class="clickable" (click)="openLog = l">
|
||||
<td>{{ l.time | slice: 0 : 16 }}</td>
|
||||
<td>{{ l.apiName }}</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class.brand]="l.direction === 'IN'" [class.ok]="l.direction === 'OUT'">{{
|
||||
l.direction
|
||||
}}</span>
|
||||
</td>
|
||||
<td>{{ l.httpStatus }}</td>
|
||||
<td class="num">{{ l.durationMs }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="cic-badge"
|
||||
[class.good]="l.result === 'SUCCESS'"
|
||||
[class.bad]="l.result === 'FAILED'"
|
||||
>{{ l.result }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="cic-mono" style="font-size: 12px">{{ l.reference }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- API editor -->
|
||||
@if (editing) {
|
||||
<div class="cic-modal-backdrop" (click)="editing = null">
|
||||
<div class="cic-modal" style="width: min(1100px, 100%)" (click)="$event.stopPropagation()">
|
||||
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: flex-start">
|
||||
<h3 style="margin: 0">
|
||||
{{ isNewApi ? 'New' : 'Edit' }} API
|
||||
<span class="cic-badge" [class.brand]="editing.direction === 'IN'" [class.ok]="editing.direction === 'OUT'">{{
|
||||
editing.direction
|
||||
}}</span>
|
||||
@if (editing.mode) {
|
||||
<span class="muted" style="font-size: 12px; font-weight: 500">
|
||||
{{ editing.mode === 'PULL' ? 'pull' : 'push' }}</span
|
||||
>
|
||||
}
|
||||
</h3>
|
||||
<button class="cic-btn sm primary" (click)="openImport()">⬆ Import from JSON / cURL / Postman</button>
|
||||
</div>
|
||||
<p class="muted" style="font-size: 12px; margin: 6px 0 10px">
|
||||
@if (editing.direction === 'OUT') {
|
||||
OUT — the CBS calls the vendor: fill the request it sends and the response it expects.
|
||||
} @else if (editing.mode === 'PULL') {
|
||||
IN (pull) — the CBS asks the vendor for data on a schedule: fill the request it sends and the response it
|
||||
reads.
|
||||
} @else {
|
||||
IN (push) — the vendor calls a CBS path: fill what the vendor sends us and what we answer.
|
||||
}
|
||||
Type everything in, or read it from a file with the import button — then check each tab.
|
||||
</p>
|
||||
|
||||
@if (info) {
|
||||
<div class="cic-alert success">{{ info }}</div>
|
||||
}
|
||||
@if (error) {
|
||||
<div class="cic-alert error">{{ error }}</div>
|
||||
}
|
||||
|
||||
<div class="cic-tabs">
|
||||
<button [class.active]="editorTab === 'basics'" (click)="editorTab = 'basics'">1 · Basics</button>
|
||||
<button [class.active]="editorTab === 'request'" (click)="editorTab = 'request'">
|
||||
2 · {{ outbound ? 'Request' : 'What the vendor sends' }}
|
||||
</button>
|
||||
<button [class.active]="editorTab === 'response'" (click)="editorTab = 'response'">
|
||||
3 · {{ outbound ? 'Response' : 'Our answer' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- BASICS -->
|
||||
@if (editorTab === 'basics') {
|
||||
<div class="cic-grid cols-4">
|
||||
<div class="cic-field" style="grid-column: span 2">
|
||||
<label>API name</label>
|
||||
<input [(ngModel)]="editing.name" placeholder="e.g. Send SMS" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Direction</label>
|
||||
<select [(ngModel)]="editing.direction" (ngModelChange)="directionChanged()">
|
||||
<option value="OUT">OUT — CBS calls vendor</option>
|
||||
<option value="IN">IN — data comes into CBS</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (editing.direction === 'IN') {
|
||||
<div class="cic-field">
|
||||
<label>How data arrives</label>
|
||||
<select [(ngModel)]="editing.mode">
|
||||
<option value="PULL">PULL — CBS asks on a schedule</option>
|
||||
<option value="PUSH">PUSH — vendor calls a CBS path</option>
|
||||
</select>
|
||||
</div>
|
||||
} @else {
|
||||
<div></div>
|
||||
}
|
||||
<div class="cic-field">
|
||||
<label>HTTP method</label>
|
||||
<select [(ngModel)]="editing.method">
|
||||
@for (m of methods; track m) {
|
||||
<option [value]="m">{{ m }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="cic-field" style="grid-column: span 3">
|
||||
<label>{{ outbound ? "Vendor's API path (after the base URL)" : 'CBS path the vendor calls' }}</label>
|
||||
<input
|
||||
class="cic-mono"
|
||||
[(ngModel)]="editing.path"
|
||||
(ngModelChange)="pathChanged()"
|
||||
placeholder="/messages/send"
|
||||
/>
|
||||
<span class="hint"
|
||||
>Use {{ '{' }}name{{ '}' }} for a value that changes, e.g. /v21.0/{{ '{' }}senderId{{
|
||||
'}'
|
||||
}}/messages.</span
|
||||
>
|
||||
</div>
|
||||
<div class="cic-field" style="grid-column: span 3">
|
||||
<label>When is it used</label>
|
||||
<input [(ngModel)]="editing.usedFor" placeholder="e.g. OTP and transaction alerts" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Timeout for this API (ms)</label>
|
||||
<input type="number" min="1000" max="300000" [(ngModel)]="editing.timeoutMs" placeholder="vendor's" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Retries for this API</label>
|
||||
<input type="number" min="0" max="5" [(ngModel)]="editing.retries" placeholder="vendor's" />
|
||||
</div>
|
||||
</div>
|
||||
<label style="display: flex; gap: 8px; align-items: center; font-size: 13px; margin: 12px 0">
|
||||
<input type="checkbox" [(ngModel)]="editing.active" /> API active
|
||||
</label>
|
||||
|
||||
@if (preview(editing); as p) {
|
||||
<h4 style="margin: 14px 0 6px; font-size: 13px">
|
||||
{{ outbound ? 'The call as it will go out' : 'The call the vendor will make to us' }}
|
||||
<span class="muted" style="font-weight: 400"> (credentials masked)</span>
|
||||
</h4>
|
||||
<pre class="cic-code" style="max-height: 220px">{{ p.method }} {{ p.url }}
|
||||
@for (h of p.headers; track h.key) {
|
||||
{{ h.key }}: {{ h.value }}
|
||||
}
|
||||
{{ p.body }}</pre>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- REQUEST -->
|
||||
@if (editorTab === 'request') {
|
||||
<h4 style="margin: 4px 0 6px; font-size: 13px">Path parameters</h4>
|
||||
<mifosx-api-key-value-editor
|
||||
[rows]="editing.pathParams"
|
||||
keyLabel="Parameter"
|
||||
valueLabel="Value (a CBS placeholder or fixed value)"
|
||||
[fixedKeys]="true"
|
||||
emptyText="The path has no {name} placeholder."
|
||||
></mifosx-api-key-value-editor>
|
||||
|
||||
<h4 style="margin: 16px 0 6px; font-size: 13px">Query parameters</h4>
|
||||
<mifosx-api-key-value-editor
|
||||
[rows]="editing.queryParams"
|
||||
keyLabel="Parameter"
|
||||
keyPlaceholder="version"
|
||||
addLabel="+ Add query parameter"
|
||||
emptyText="No query parameter."
|
||||
></mifosx-api-key-value-editor>
|
||||
|
||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
||||
Headers
|
||||
<span class="muted" style="font-weight: 400"
|
||||
>— this API's own; the vendor's default headers and its authentication are added automatically</span
|
||||
>
|
||||
</h4>
|
||||
<mifosx-api-key-value-editor
|
||||
[rows]="editing.headers"
|
||||
keyLabel="Header"
|
||||
keyPlaceholder="X-Request-Id"
|
||||
valuePlaceholder="{{ '{{' }}request.reference{{ '}}' }}"
|
||||
addLabel="+ Add header"
|
||||
emptyText="No header of its own."
|
||||
></mifosx-api-key-value-editor>
|
||||
|
||||
<div class="cic-grid cols-4" style="margin-top: 16px">
|
||||
<div class="cic-field" style="grid-column: span 2">
|
||||
<label>Content type</label>
|
||||
<select [(ngModel)]="editing.contentType">
|
||||
@for (c of contentTypes; track c.key) {
|
||||
<option [value]="c.key">{{ c.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cic-field" style="margin-top: 12px">
|
||||
<label>{{ outbound ? 'Request payload' : 'Payload the vendor sends us' }}</label>
|
||||
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.requestBody"></textarea>
|
||||
<span class="hint">
|
||||
{{
|
||||
outbound
|
||||
? 'The constant parts of the payload; the mapping below puts CBS values into it.'
|
||||
: 'A sample of what arrives; the mapping reads values out of it.'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
||||
{{ mappingTitle('request') }}
|
||||
<span class="muted" style="font-weight: 400; font-size: 12px">
|
||||
· {{ matchedCount('request') }} of {{ editing.requestRows.length }} rows found in the sample
|
||||
{{ 'request' === 'request' ? 'payload' : 'body' }}
|
||||
</span>
|
||||
<button class="cic-btn sm" style="margin-left: 8px" (click)="generateRows('request')">
|
||||
Build rows from the payload
|
||||
</button>
|
||||
</h4>
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ fromLabel('request') }}</th>
|
||||
<th>{{ toLabel('request') }}</th>
|
||||
<th>Transform</th>
|
||||
<th>Fixed / default value</th>
|
||||
<th>In the sample payload</th>
|
||||
<th>Required</th>
|
||||
<th style="width: 40px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@let requestMatch = sampleMatches('request');
|
||||
@for (r of editing.requestRows; track $index; let i = $index) {
|
||||
<tr>
|
||||
<td>
|
||||
@if (fromIsCbs('request')) {
|
||||
<select [(ngModel)]="r.from">
|
||||
<option value="">— fixed value (set it on the right) —</option>
|
||||
@for (f of cbsFields; track f.key) {
|
||||
<option [value]="f.key">{{ f.label }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<input class="cic-mono" [(ngModel)]="r.from" />
|
||||
}
|
||||
</td>
|
||||
<td><input class="cic-mono" [(ngModel)]="r.to" /></td>
|
||||
<td>
|
||||
<select [(ngModel)]="r.transform">
|
||||
@for (t of transforms; track t.key) {
|
||||
<option [value]="t.key">{{ t.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td><input [(ngModel)]="r.defaultValue" /></td>
|
||||
<td class="cic-mono" style="font-size: 12px; white-space: nowrap">
|
||||
@if (requestMatch[i].found) {
|
||||
<span class="tone-good">✓ {{ requestMatch[i].value }}</span>
|
||||
} @else if (r.defaultValue) {
|
||||
<span class="muted">fixed: {{ r.defaultValue }}</span>
|
||||
} @else {
|
||||
<span class="tone-bad">✗ {{ requestMatch[i].value || 'no path' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td style="text-align: center">
|
||||
<input type="checkbox" [(ngModel)]="r.mandatory" style="width: auto" />
|
||||
</td>
|
||||
<td><button class="cic-btn sm danger" (click)="removeRow('request', i)">✕</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button class="cic-btn sm" style="margin-top: 8px" (click)="addRow('request')">+ Add field</button>
|
||||
}
|
||||
|
||||
<!-- RESPONSE -->
|
||||
@if (editorTab === 'response') {
|
||||
<div class="cic-grid cols-4">
|
||||
<div class="cic-field">
|
||||
<label>Success status code(s)</label>
|
||||
<input class="cic-mono" [(ngModel)]="editing.successCodes" placeholder="200, 202" />
|
||||
<span class="hint">{{
|
||||
outbound ? 'Codes the vendor answers with when it worked.' : 'Codes the CBS answers with.'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
||||
{{ outbound ? 'Response headers' : 'Headers of our answer' }}
|
||||
</h4>
|
||||
<mifosx-api-key-value-editor
|
||||
[rows]="editing.responseHeaders"
|
||||
keyLabel="Header"
|
||||
keyPlaceholder="Content-Type"
|
||||
addLabel="+ Add header"
|
||||
emptyText="No response header noted."
|
||||
></mifosx-api-key-value-editor>
|
||||
|
||||
<div class="cic-grid cols-2" style="margin-top: 16px">
|
||||
<div class="cic-field">
|
||||
<label>{{ outbound ? 'Success response body' : 'Our answer body' }}</label>
|
||||
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.responseBody"></textarea>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Error response body</label>
|
||||
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.errorBody"></textarea>
|
||||
<span class="hint"
|
||||
>What comes back when it fails (wrong key, rate limit …) — shown in the logs for a failed call.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
||||
{{ mappingTitle('response') }}
|
||||
<span class="muted" style="font-weight: 400; font-size: 12px">
|
||||
· {{ matchedCount('response') }} of {{ editing.responseRows.length }} rows found in the sample
|
||||
{{ 'response' === 'request' ? 'payload' : 'body' }}
|
||||
</span>
|
||||
<button class="cic-btn sm" style="margin-left: 8px" (click)="generateRows('response')">
|
||||
Build rows from the body
|
||||
</button>
|
||||
</h4>
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ fromLabel('response') }}</th>
|
||||
<th>{{ toLabel('response') }}</th>
|
||||
<th>Transform</th>
|
||||
<th>Fixed / default value</th>
|
||||
<th>In the sample payload</th>
|
||||
<th>Required</th>
|
||||
<th style="width: 40px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@let responseMatch = sampleMatches('response');
|
||||
@for (r of editing.responseRows; track $index; let i = $index) {
|
||||
<tr>
|
||||
<td>
|
||||
@if (fromIsCbs('response')) {
|
||||
<select [(ngModel)]="r.from">
|
||||
<option value="">— fixed value (set it on the right) —</option>
|
||||
@for (f of cbsFields; track f.key) {
|
||||
<option [value]="f.key">{{ f.label }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<input class="cic-mono" [(ngModel)]="r.from" />
|
||||
}
|
||||
</td>
|
||||
<td><input class="cic-mono" [(ngModel)]="r.to" /></td>
|
||||
<td>
|
||||
<select [(ngModel)]="r.transform">
|
||||
@for (t of transforms; track t.key) {
|
||||
<option [value]="t.key">{{ t.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</td>
|
||||
<td><input [(ngModel)]="r.defaultValue" /></td>
|
||||
<td class="cic-mono" style="font-size: 12px; white-space: nowrap">
|
||||
@if (responseMatch[i].found) {
|
||||
<span class="tone-good">✓ {{ responseMatch[i].value }}</span>
|
||||
} @else if (r.defaultValue) {
|
||||
<span class="muted">fixed: {{ r.defaultValue }}</span>
|
||||
} @else {
|
||||
<span class="tone-bad">✗ {{ responseMatch[i].value || 'no path' }}</span>
|
||||
}
|
||||
</td>
|
||||
<td style="text-align: center">
|
||||
<input type="checkbox" [(ngModel)]="r.mandatory" style="width: auto" />
|
||||
</td>
|
||||
<td><button class="cic-btn sm danger" (click)="removeRow('response', i)">✕</button></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button class="cic-btn sm" style="margin-top: 8px" (click)="addRow('response')">+ Add field</button>
|
||||
}
|
||||
|
||||
<div class="foot">
|
||||
<button class="cic-btn" (click)="editing = null">Cancel</button>
|
||||
<button class="cic-btn primary" (click)="saveApi()" [disabled]="savingApi">
|
||||
{{ savingApi ? 'Saving…' : 'Save API' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Import from JSON / cURL / Postman / HAR -->
|
||||
@if (importOpen && editing) {
|
||||
<div class="cic-modal-backdrop" style="z-index: 1100" (click)="importOpen = false">
|
||||
<div class="cic-modal" style="width: min(1000px, 100%)" (click)="$event.stopPropagation()">
|
||||
<h3>Import from JSON / cURL / Postman</h3>
|
||||
<p class="muted" style="font-size: 12px; margin: 0 0 10px">
|
||||
Paste it or choose a file —
|
||||
{{ outbound ? 'the request and the response' : 'what the vendor sends us and what we answer' }}. Any of these
|
||||
works: a <b>cURL</b> command, a <b>Postman</b> collection or request, a <b>HAR</b> capture, an API definition
|
||||
(<code>{{ '{' }} method, url, headers, query, body, status … {{ '}' }}</code
|
||||
>), or just the <b>JSON payload</b>. The method, URL / path, query parameters, headers, content type, payload,
|
||||
status code, response headers, bodies and a first CBS mapping are filled in for you. Passwords, keys and tokens
|
||||
are never kept.
|
||||
</p>
|
||||
@if (importError) {
|
||||
<div class="cic-alert error">{{ importError }}</div>
|
||||
}
|
||||
<div class="cic-grid cols-2">
|
||||
<div class="cic-field">
|
||||
<label
|
||||
>{{ outbound ? 'Request' : 'What the vendor sends us' }} — cURL, Postman, HAR, definition or payload</label
|
||||
>
|
||||
<textarea
|
||||
class="cic-mono"
|
||||
rows="12"
|
||||
[(ngModel)]="importRequestText"
|
||||
placeholder="curl -X POST 'https://api.vendor.in/v1/send' -H 'Content-Type: application/json' -d '{ ... }'"
|
||||
></textarea>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.txt,.curl,.har,.sh,application/json,text/plain"
|
||||
(change)="onFile($event, 'request')"
|
||||
/>
|
||||
@if (importRequestFile) {
|
||||
<span class="muted" style="font-size: 12px">{{ importRequestFile }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>{{ outbound ? 'Response' : 'Our answer' }} — JSON body, or with status and headers (optional)</label>
|
||||
<textarea
|
||||
class="cic-mono"
|
||||
rows="12"
|
||||
[(ngModel)]="importResponseText"
|
||||
placeholder='{ "status": 200, "headers": { "Content-Type": "application/json" }, "body": { ... } }'
|
||||
></textarea>
|
||||
<div style="display: flex; gap: 8px; align-items: center">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.txt,.har,application/json,text/plain"
|
||||
(change)="onFile($event, 'response')"
|
||||
/>
|
||||
@if (importResponseFile) {
|
||||
<span class="muted" style="font-size: 12px">{{ importResponseFile }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 20px; flex-wrap: wrap; margin-top: 12px; font-size: 13px">
|
||||
<label style="display: flex; gap: 6px; align-items: center">
|
||||
<input type="checkbox" [(ngModel)]="importReplace" /> Replace what is there now (headers, query, mapping rows)
|
||||
— untick to add to it
|
||||
</label>
|
||||
<label style="display: flex; gap: 6px; align-items: center">
|
||||
<input type="checkbox" [(ngModel)]="importGenerate" /> Build the CBS field mapping from the payloads
|
||||
</label>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<button class="cic-btn" (click)="importOpen = false">Cancel</button>
|
||||
<button class="cic-btn primary" (click)="applyImport()">Read and fill in</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mifosx-api-log-detail [log]="openLog" (closed)="openLog = null"></mifosx-api-log-detail>
|
||||
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
|
||||
import { ApiKeyValueEditorComponent } from '../api-key-value-editor/api-key-value-editor.component';
|
||||
import { ApiLogDetailComponent } from '../api-log-detail/api-log-detail.component';
|
||||
import {
|
||||
guessRequestRows,
|
||||
guessResponseRows,
|
||||
ImportedRequest,
|
||||
ImportedResponse,
|
||||
parseRequestText,
|
||||
parseResponseText
|
||||
} from '../api-import.util';
|
||||
import {
|
||||
API_AUTH_TYPES,
|
||||
API_CBS_FIELDS,
|
||||
API_CONTENT_TYPES,
|
||||
API_METHODS,
|
||||
API_TRANSFORMS,
|
||||
ApiLog,
|
||||
ApiManagementService,
|
||||
ApiMappingRow,
|
||||
ApiVendor,
|
||||
blankApi,
|
||||
buildRequestPreview,
|
||||
callsVendor,
|
||||
KeyValue,
|
||||
pathParamNames,
|
||||
RequestPreview,
|
||||
VendorApi
|
||||
} from '../api-management.service';
|
||||
|
||||
/**
|
||||
* One vendor's setup: its connection (URL, authentication, default headers, credentials), its API set - each API IN or
|
||||
* OUT with the full HTTP detail and the field mapping, typed in or read from a JSON / cURL / Postman / HAR file - a
|
||||
* test call, and its recent calls.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-api-vendor-setup',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
ApiLogDetailComponent,
|
||||
ApiKeyValueEditorComponent
|
||||
],
|
||||
templateUrl: './api-vendor-setup.component.html',
|
||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
||||
})
|
||||
export class ApiVendorSetupComponent implements OnInit {
|
||||
private service = inject(ApiManagementService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
vendor: ApiVendor | null = null;
|
||||
tab: 'connection' | 'apis' | 'test' | 'calls' = 'connection';
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
info: string | null = null;
|
||||
|
||||
// connection
|
||||
form: any = {};
|
||||
saving = false;
|
||||
|
||||
// API set
|
||||
editing: VendorApi | null = null;
|
||||
isNewApi = false;
|
||||
savingApi = false;
|
||||
editorTab: 'basics' | 'request' | 'response' = 'basics';
|
||||
|
||||
// import from JSON / cURL / Postman / HAR
|
||||
importOpen = false;
|
||||
importRequestText = '';
|
||||
importResponseText = '';
|
||||
importRequestFile = '';
|
||||
importResponseFile = '';
|
||||
importReplace = true;
|
||||
importGenerate = true;
|
||||
importError: string | null = null;
|
||||
|
||||
// test
|
||||
testApiId: number | null = null;
|
||||
testBody = '';
|
||||
testing = false;
|
||||
testResult: ApiLog | null = null;
|
||||
|
||||
// calls
|
||||
calls: ApiLog[] = [];
|
||||
openLog: ApiLog | null = null;
|
||||
|
||||
readonly authTypes = Object.entries(API_AUTH_TYPES).map(
|
||||
([
|
||||
key,
|
||||
label
|
||||
]) => ({ key, label })
|
||||
);
|
||||
readonly contentTypes = Object.entries(API_CONTENT_TYPES).map(
|
||||
([
|
||||
key,
|
||||
label
|
||||
]) => ({ key, label })
|
||||
);
|
||||
readonly transforms = Object.entries(API_TRANSFORMS).map(
|
||||
([
|
||||
key,
|
||||
label
|
||||
]) => ({ key, label })
|
||||
);
|
||||
readonly cbsFields = Object.entries(API_CBS_FIELDS).map(
|
||||
([
|
||||
key,
|
||||
label
|
||||
]) => ({ key, label })
|
||||
);
|
||||
readonly methods = API_METHODS;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load(Number(this.route.snapshot.paramMap.get('id')));
|
||||
}
|
||||
|
||||
load(id: number): void {
|
||||
this.loading = true;
|
||||
this.service.vendor(id).subscribe({
|
||||
next: (v) => {
|
||||
this.vendor = v;
|
||||
this.loading = false;
|
||||
this.resetForm(v);
|
||||
if (!this.testApiId || !v.apis.some((a) => a.id === this.testApiId)) this.testApiId = v.apis[0]?.id ?? null;
|
||||
this.fillTestBody();
|
||||
this.loadCalls();
|
||||
},
|
||||
error: (e) => {
|
||||
this.loading = false;
|
||||
this.error = e?.error?.defaultUserMessage ?? 'Could not load the vendor.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resetForm(v: ApiVendor): void {
|
||||
this.form = {
|
||||
name: v.name,
|
||||
work: v.work,
|
||||
environment: v.environment,
|
||||
baseUrl: v.baseUrl,
|
||||
authType: v.authType,
|
||||
username: v.username,
|
||||
apiKeyHeader: v.apiKeyHeader,
|
||||
secret: '',
|
||||
clearSecret: false,
|
||||
senderId: v.senderId,
|
||||
timeoutMs: v.timeoutMs,
|
||||
retries: v.retries,
|
||||
defaultHeaders: JSON.parse(JSON.stringify(v.defaultHeaders)),
|
||||
active: v.active
|
||||
};
|
||||
}
|
||||
|
||||
loadCalls(): void {
|
||||
if (!this.vendor) return;
|
||||
this.service.logs({ vendorId: String(this.vendor.id), limit: 12 }).subscribe((r) => (this.calls = r.items));
|
||||
}
|
||||
|
||||
// ── connection ──
|
||||
|
||||
needsSecret(): boolean {
|
||||
return this.form.authType !== 'NONE';
|
||||
}
|
||||
|
||||
secretLabel(): string {
|
||||
const a = this.form.authType;
|
||||
return a === 'BASIC'
|
||||
? 'Password'
|
||||
: a === 'API_KEY'
|
||||
? 'API key'
|
||||
: a === 'BEARER_TOKEN'
|
||||
? 'Bearer token'
|
||||
: 'Client secret';
|
||||
}
|
||||
|
||||
saveConnection(): void {
|
||||
if (!this.vendor) return;
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.info = null;
|
||||
const body = { ...this.form };
|
||||
if (!body.secret) delete body.secret;
|
||||
this.service.saveVendor(this.vendor.id, body).subscribe({
|
||||
next: (v) => {
|
||||
this.saving = false;
|
||||
this.vendor = v;
|
||||
this.resetForm(v);
|
||||
this.info = `${v.name} connection saved.`;
|
||||
},
|
||||
error: (e) => {
|
||||
this.saving = false;
|
||||
this.error = e?.error?.defaultUserMessage ?? 'Could not save the connection.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── API set ──
|
||||
|
||||
newApi(direction: 'IN' | 'OUT'): void {
|
||||
this.isNewApi = true;
|
||||
this.error = null;
|
||||
this.info = null;
|
||||
this.editorTab = 'basics';
|
||||
this.editing = blankApi(direction);
|
||||
}
|
||||
|
||||
editApi(a: VendorApi): void {
|
||||
this.isNewApi = false;
|
||||
this.error = null;
|
||||
this.info = null;
|
||||
this.editorTab = 'basics';
|
||||
this.editing = JSON.parse(JSON.stringify(a));
|
||||
}
|
||||
|
||||
/** True when the CBS makes the HTTP call (OUT, or IN pull); false for an IN push webhook. */
|
||||
get outbound(): boolean {
|
||||
return !!this.editing && callsVendor(this.editing);
|
||||
}
|
||||
|
||||
directionChanged(): void {
|
||||
if (!this.editing) return;
|
||||
this.editing.mode = this.editing.direction === 'IN' ? (this.editing.mode ?? 'PUSH') : undefined;
|
||||
}
|
||||
|
||||
/** Keeps the path-parameter table in step with the {names} typed in the path. */
|
||||
pathChanged(): void {
|
||||
if (!this.editing) return;
|
||||
const names = pathParamNames(this.editing.path);
|
||||
const kept = this.editing.pathParams.filter((p) => names.includes(p.key));
|
||||
for (const n of names) {
|
||||
if (!kept.some((p) => p.key === n))
|
||||
kept.push({ key: n, value: '', description: '', enabled: true, secret: false });
|
||||
}
|
||||
this.editing.pathParams = kept;
|
||||
}
|
||||
|
||||
rowsOf(side: 'request' | 'response'): ApiMappingRow[] {
|
||||
if (!this.editing) return [];
|
||||
return side === 'request' ? this.editing.requestRows : this.editing.responseRows;
|
||||
}
|
||||
|
||||
/** Whether the left column of a mapping table holds a CBS value (a pick list) or a path in the vendor's JSON. */
|
||||
fromIsCbs(side: 'request' | 'response'): boolean {
|
||||
return this.outbound ? side === 'request' : side === 'response';
|
||||
}
|
||||
|
||||
mappingTitle(side: 'request' | 'response'): string {
|
||||
if (this.outbound) return side === 'request' ? 'Request mapping (CBS → vendor)' : 'Response mapping (vendor → CBS)';
|
||||
return side === 'request' ? 'Incoming data mapping (vendor → CBS)' : 'Answer mapping (CBS → vendor)';
|
||||
}
|
||||
|
||||
fromLabel(side: 'request' | 'response'): string {
|
||||
return this.fromIsCbs(side) ? 'CBS value' : "Vendor's JSON path";
|
||||
}
|
||||
|
||||
toLabel(side: 'request' | 'response'): string {
|
||||
if (this.fromIsCbs(side)) return side === 'request' ? "Vendor's request path" : 'Answer path';
|
||||
return 'CBS field';
|
||||
}
|
||||
|
||||
private parsedCache = new Map<string, unknown>();
|
||||
|
||||
private parsed(text: string): unknown {
|
||||
if (!this.parsedCache.has(text)) {
|
||||
let v: unknown = undefined;
|
||||
try {
|
||||
v = JSON.parse(text);
|
||||
} catch {
|
||||
v = undefined;
|
||||
}
|
||||
if (this.parsedCache.size > 20) this.parsedCache.clear();
|
||||
this.parsedCache.set(text, v);
|
||||
}
|
||||
return this.parsedCache.get(text);
|
||||
}
|
||||
|
||||
/** Reads a.b[0].c out of a parsed payload; undefined when it is not there. */
|
||||
private readPath(root: unknown, path: string): unknown {
|
||||
let cur: any = root;
|
||||
for (const part of path.match(/[^.[\]]+/g) ?? []) {
|
||||
if (cur === null || cur === undefined) return undefined;
|
||||
cur = cur[/^\d+$/.test(part) ? Number(part) : part];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a mapping row matches the sample payload on its side: the value found at the row's JSON path. Rows after a
|
||||
* list (e.g. data.alerts) are read from the first item of that list, as the CBS does for each item.
|
||||
*/
|
||||
sampleMatches(side: 'request' | 'response'): { found: boolean; value: string }[] {
|
||||
if (!this.editing) return [];
|
||||
const body = this.parsed(side === 'request' ? this.editing.requestBody : this.editing.responseBody);
|
||||
const pathIsTo = this.fromIsCbs(side);
|
||||
let item: unknown = undefined;
|
||||
return this.rowsOf(side).map((r) => {
|
||||
const path = (pathIsTo ? r.to : r.from).trim();
|
||||
if (!path || body === undefined) return { found: false, value: body === undefined ? 'payload is not JSON' : '' };
|
||||
let v = this.readPath(body, path);
|
||||
if (v === undefined && item !== undefined) v = this.readPath(item, path);
|
||||
if (Array.isArray(v)) {
|
||||
if (item === undefined && v.length) item = v[0];
|
||||
return { found: true, value: `list of ${v.length}` };
|
||||
}
|
||||
if (v === undefined) return { found: false, value: 'not in the sample payload' };
|
||||
const text = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
||||
return { found: true, value: text.length > 40 ? text.slice(0, 40) + '…' : text };
|
||||
});
|
||||
}
|
||||
|
||||
matchedCount(side: 'request' | 'response'): number {
|
||||
return this.sampleMatches(side).filter((m) => m.found).length;
|
||||
}
|
||||
|
||||
addRow(side: 'request' | 'response'): void {
|
||||
this.rowsOf(side).push({ from: '', to: '', transform: 'NONE', defaultValue: '', mandatory: false });
|
||||
}
|
||||
|
||||
removeRow(side: 'request' | 'response', i: number): void {
|
||||
this.rowsOf(side).splice(i, 1);
|
||||
}
|
||||
|
||||
/** Builds mapping rows from the payload typed or imported for that side. */
|
||||
generateRows(side: 'request' | 'response'): void {
|
||||
if (!this.editing || !this.vendor) return;
|
||||
const body = side === 'request' ? this.editing.requestBody : this.editing.responseBody;
|
||||
// Reading values OUT of a payload (vendor → CBS) vs putting values INTO one (CBS → vendor).
|
||||
const reading = !this.fromIsCbs(side);
|
||||
const rows = reading ? guessResponseRows(body, this.vendor.code) : guessRequestRows(body);
|
||||
if (!rows.length) {
|
||||
this.error = 'That payload has no JSON values to build a mapping from.';
|
||||
return;
|
||||
}
|
||||
this.error = null;
|
||||
const target = this.rowsOf(side);
|
||||
const have = new Set(target.map((r) => r.to));
|
||||
target.push(...rows.filter((r) => !have.has(r.to)));
|
||||
this.info = `${rows.length} mapping row(s) read from the payload. Review the CBS fields and adjust.`;
|
||||
}
|
||||
|
||||
saveApi(): void {
|
||||
if (!this.vendor || !this.editing) return;
|
||||
this.savingApi = true;
|
||||
this.error = null;
|
||||
this.service.saveApi(this.vendor.id, this.editing).subscribe({
|
||||
next: (a) => {
|
||||
this.savingApi = false;
|
||||
this.info = `${a.name} saved.`;
|
||||
this.editing = null;
|
||||
this.load(this.vendor!.id);
|
||||
},
|
||||
error: (e) => {
|
||||
this.savingApi = false;
|
||||
this.error = e?.error?.defaultUserMessage ?? 'Could not save the API.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteApi(a: VendorApi): void {
|
||||
if (!this.vendor || !confirm(`Delete the API "${a.name}"?`)) return;
|
||||
this.service.deleteApi(this.vendor.id, a.id).subscribe(() => {
|
||||
this.editing = null;
|
||||
this.info = `${a.name} deleted.`;
|
||||
this.load(this.vendor!.id);
|
||||
});
|
||||
}
|
||||
|
||||
/** The request this API would send (or receive), as the Test tab and the editor show it. */
|
||||
preview(a: VendorApi | null): RequestPreview | null {
|
||||
return this.vendor && a ? buildRequestPreview(this.vendor, a) : null;
|
||||
}
|
||||
|
||||
// ── import ──
|
||||
|
||||
openImport(): void {
|
||||
this.importOpen = true;
|
||||
this.importError = null;
|
||||
this.importReplace = this.isNewApi;
|
||||
this.importRequestText = '';
|
||||
this.importResponseText = '';
|
||||
this.importRequestFile = '';
|
||||
this.importResponseFile = '';
|
||||
}
|
||||
|
||||
onFile(event: Event, which: 'request' | 'response'): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
this.importError = 'The file is larger than 5 MB.';
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = String(reader.result ?? '');
|
||||
if (which === 'request') {
|
||||
this.importRequestText = text;
|
||||
this.importRequestFile = file.name;
|
||||
} else {
|
||||
this.importResponseText = text;
|
||||
this.importResponseFile = file.name;
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
applyImport(): void {
|
||||
if (!this.editing || !this.vendor) return;
|
||||
this.importError = null;
|
||||
let req: ImportedRequest | null = null;
|
||||
let res: ImportedResponse | null = null;
|
||||
try {
|
||||
if (this.importRequestText.trim()) req = parseRequestText(this.importRequestText);
|
||||
if (this.importResponseText.trim()) res = parseResponseText(this.importResponseText);
|
||||
else if (req?.response) res = req.response;
|
||||
} catch (e: any) {
|
||||
this.importError = e?.message ?? 'Could not read the file.';
|
||||
return;
|
||||
}
|
||||
if (!req && !res) {
|
||||
this.importError = 'Paste or choose a request, a response, or both.';
|
||||
return;
|
||||
}
|
||||
|
||||
const a = this.editing;
|
||||
const done: string[] = [];
|
||||
const notes: string[] = [
|
||||
...(req?.notes ?? []),
|
||||
...(res?.notes ?? [])
|
||||
];
|
||||
const replace = this.importReplace;
|
||||
|
||||
const mergePairs = (target: KeyValue[], incoming: KeyValue[]): KeyValue[] => {
|
||||
if (replace) return incoming;
|
||||
const out = [...target];
|
||||
for (const p of incoming) {
|
||||
const i = out.findIndex((x) => x.key.toLowerCase() === p.key.toLowerCase());
|
||||
if (i >= 0) out[i] = p;
|
||||
else out.push(p);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
if (req) {
|
||||
if (req.method && this.methods.includes(req.method)) {
|
||||
a.method = req.method as VendorApi['method'];
|
||||
done.push(`method ${req.method}`);
|
||||
}
|
||||
if (req.path && req.path !== '/') {
|
||||
a.path = this.stripVendorBasePath(req.path);
|
||||
done.push(`path ${a.path}`);
|
||||
}
|
||||
if (req.baseUrl) {
|
||||
if (!this.form.baseUrl) {
|
||||
this.form.baseUrl = req.baseUrl;
|
||||
notes.push(`Base URL ${req.baseUrl} was put on the Connection tab - save it there.`);
|
||||
} else if (this.form.baseUrl.replace(/\/+$/, '') !== req.baseUrl) {
|
||||
notes.push(
|
||||
`The file's host (${req.baseUrl}) is not this vendor's base URL (${this.form.baseUrl}); only the path was used.`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (req.queryParams.length) {
|
||||
a.queryParams = mergePairs(a.queryParams, req.queryParams);
|
||||
done.push(`${req.queryParams.length} query parameter(s)`);
|
||||
}
|
||||
if (req.headers.length) {
|
||||
a.headers = mergePairs(a.headers, req.headers);
|
||||
done.push(`${req.headers.length} header(s)`);
|
||||
}
|
||||
if (req.contentType) {
|
||||
a.contentType = Object.keys(API_CONTENT_TYPES).includes(req.contentType) ? req.contentType : a.contentType;
|
||||
done.push(`content type ${a.contentType}`);
|
||||
}
|
||||
if (req.body !== undefined) {
|
||||
a.requestBody = req.body;
|
||||
done.push('request payload');
|
||||
}
|
||||
if (!a.name.trim() && a.path) {
|
||||
const last =
|
||||
a.path
|
||||
.split('/')
|
||||
.filter((s) => s && !s.includes('{'))
|
||||
.pop() ?? 'API';
|
||||
a.name = last.charAt(0).toUpperCase() + last.slice(1).replace(/[-_]+/g, ' ');
|
||||
}
|
||||
this.pathChanged();
|
||||
}
|
||||
|
||||
if (res) {
|
||||
const failure = res.status !== undefined && (res.status < 200 || res.status > 299);
|
||||
if (res.status !== undefined && !failure) {
|
||||
a.successCodes = String(res.status);
|
||||
done.push(`success code ${res.status}`);
|
||||
}
|
||||
if (res.headers.length) {
|
||||
a.responseHeaders = mergePairs(a.responseHeaders, res.headers);
|
||||
done.push(`${res.headers.length} response header(s)`);
|
||||
}
|
||||
if (res.body !== undefined) {
|
||||
if (failure) {
|
||||
a.errorBody = res.body;
|
||||
done.push(`error body (HTTP ${res.status})`);
|
||||
} else {
|
||||
a.responseBody = res.body;
|
||||
done.push('response body');
|
||||
}
|
||||
}
|
||||
if (res.errorBody !== undefined) {
|
||||
a.errorBody = res.errorBody;
|
||||
done.push('error body');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.importGenerate) {
|
||||
const addRows = (side: 'request' | 'response', body: string) => {
|
||||
const reading = !this.fromIsCbs(side);
|
||||
const rows = reading ? guessResponseRows(body, this.vendor!.code) : guessRequestRows(body);
|
||||
if (!rows.length) return;
|
||||
const target = replace ? [] : this.rowsOf(side);
|
||||
const have = new Set(target.map((r) => r.to));
|
||||
const merged = [
|
||||
...target,
|
||||
...rows.filter((r) => !have.has(r.to))
|
||||
];
|
||||
if (side === 'request') a.requestRows = merged;
|
||||
else a.responseRows = merged;
|
||||
done.push(`${rows.length} ${side === 'request' ? 'request' : 'response'} mapping row(s)`);
|
||||
};
|
||||
if (req?.body !== undefined) addRows('request', a.requestBody);
|
||||
if (res?.body !== undefined && !(res.status !== undefined && (res.status < 200 || res.status > 299)))
|
||||
addRows('response', a.responseBody);
|
||||
}
|
||||
|
||||
this.importOpen = false;
|
||||
this.error = null;
|
||||
this.info = `Read from ${[
|
||||
req?.source,
|
||||
res?.source
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' + ')}: ${done.join(', ') || 'nothing new'}. ${notes.join(' ')} Review each tab, then Save API.`;
|
||||
}
|
||||
|
||||
/** A path read from a full URL may repeat the path already in the vendor's base URL. */
|
||||
private stripVendorBasePath(path: string): string {
|
||||
const base = String(this.form.baseUrl ?? '');
|
||||
const m = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(\/.+)$/.exec(base.replace(/\/+$/, ''));
|
||||
if (m && path.startsWith(m[1] + '/')) return path.slice(m[1].length);
|
||||
return path;
|
||||
}
|
||||
|
||||
// ── test ──
|
||||
|
||||
get testApi(): VendorApi | null {
|
||||
return this.vendor?.apis.find((x) => x.id === this.testApiId) ?? null;
|
||||
}
|
||||
|
||||
fillTestBody(): void {
|
||||
this.testBody = this.testApi?.requestBody ?? '{}';
|
||||
this.testResult = null;
|
||||
}
|
||||
|
||||
get testPreview(): RequestPreview | null {
|
||||
const a = this.testApi;
|
||||
return this.vendor && a ? buildRequestPreview(this.vendor, { ...a, requestBody: this.testBody }) : null;
|
||||
}
|
||||
|
||||
runTest(): void {
|
||||
if (!this.vendor || !this.testApiId) return;
|
||||
this.testing = true;
|
||||
this.error = null;
|
||||
this.service.testApi(this.vendor.id, this.testApiId, this.testBody).subscribe({
|
||||
next: (log) => {
|
||||
this.testing = false;
|
||||
this.testResult = log;
|
||||
this.loadCalls();
|
||||
},
|
||||
error: (e) => {
|
||||
this.testing = false;
|
||||
this.error = e?.error?.defaultUserMessage ?? 'Test failed.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cic-page">
|
||||
<div class="cic-head">
|
||||
<div>
|
||||
<h1>API Management</h1>
|
||||
<p>
|
||||
Connect any API service to the CBS the same way: <b>Vendor → Work → API set</b>. <b>IN</b> = data comes into the
|
||||
CBS from the vendor (CIC alerts, delivery reports, replies). <b>OUT</b> = the CBS calls the vendor (send an SMS
|
||||
or WhatsApp message).
|
||||
</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<a class="cic-btn" routerLink="/api-management/logs">API call logs</a>
|
||||
<button class="cic-btn primary" (click)="startAdd()">+ Add vendor</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error && !adding) {
|
||||
<div class="cic-alert error">{{ error }}</div>
|
||||
}
|
||||
@if (info) {
|
||||
<div class="cic-alert success">{{ info }}</div>
|
||||
}
|
||||
|
||||
<div class="cic-grid cols-4" style="margin-bottom: 16px">
|
||||
<div class="cic-stat">
|
||||
<div class="k">Vendors</div>
|
||||
<div class="v">{{ vendors.length }}</div>
|
||||
<div class="s">{{ activeVendors }} active</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">APIs</div>
|
||||
<div class="v">{{ totalIn + totalOut }}</div>
|
||||
<div class="s">{{ totalIn }} IN · {{ totalOut }} OUT</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Calls in 24 hours</div>
|
||||
<div class="v">{{ summary.callsToday }}</div>
|
||||
<div class="s">both directions</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Failed in 24 hours</div>
|
||||
<div class="v" [class.tone-bad]="summary.failedToday > 0">{{ summary.failedToday }}</div>
|
||||
<div class="s">see the call logs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cic-card">
|
||||
<h2>Vendors</h2>
|
||||
@if (loading && !vendors.length) {
|
||||
<div class="cic-empty">Loading…</div>
|
||||
} @else {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Vendor</th>
|
||||
<th>Work</th>
|
||||
<th>APIs</th>
|
||||
<th>Environment</th>
|
||||
<th>24 h calls</th>
|
||||
<th>Last call</th>
|
||||
<th>Active</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (v of vendors; track v.id) {
|
||||
<tr>
|
||||
<td>
|
||||
<b>{{ v.name }}</b>
|
||||
<div class="muted cic-mono" style="font-size: 11px">{{ v.code }}</div>
|
||||
</td>
|
||||
<td style="max-width: 340px; white-space: normal">{{ v.work }}</td>
|
||||
<td>
|
||||
@if (count(v, 'IN')) {
|
||||
<span class="cic-badge brand">IN × {{ count(v, 'IN') }}</span>
|
||||
}
|
||||
@if (count(v, 'OUT')) {
|
||||
<span class="cic-badge ok">OUT × {{ count(v, 'OUT') }}</span>
|
||||
}
|
||||
@if (!v.apis.length) {
|
||||
<span class="muted">none yet</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="cic-badge"
|
||||
[class.warn]="v.environment === 'SIMULATOR'"
|
||||
[class.good]="v.environment === 'PRODUCTION'"
|
||||
>{{ v.environment }}</span
|
||||
>
|
||||
</td>
|
||||
<td>
|
||||
{{ calls(v).calls }}
|
||||
@if (calls(v).failed) {
|
||||
<span class="tone-bad" style="font-size: 12px"> · {{ calls(v).failed }} failed</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ v.lastCallOn ? (v.lastCallOn | slice: 0 : 16) : '—' }}</td>
|
||||
<td>
|
||||
<span class="cic-switch">
|
||||
<input type="checkbox" [checked]="v.active" (change)="toggle(v, $event)" />
|
||||
<span></span>
|
||||
</span>
|
||||
</td>
|
||||
<td style="white-space: nowrap">
|
||||
<a class="cic-btn sm primary" [routerLink]="['/api-management/vendors', v.id]">Set up</a>
|
||||
</td>
|
||||
</tr>
|
||||
@if (v.links.length) {
|
||||
<tr>
|
||||
<td colspan="8" style="background: var(--cic-soft); padding: 6px 12px">
|
||||
<span class="muted" style="font-size: 12px">{{ v.code }} also has:</span>
|
||||
@for (l of v.links; track l.route) {
|
||||
<a class="cic-btn sm" style="margin-left: 6px" [routerLink]="l.route">{{ l.label }}</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (adding) {
|
||||
<div class="cic-modal-backdrop" (click)="adding = false">
|
||||
<div class="cic-modal" (click)="$event.stopPropagation()">
|
||||
<h3>Add an API vendor</h3>
|
||||
@if (error) {
|
||||
<div class="cic-alert error">{{ error }}</div>
|
||||
}
|
||||
<div class="cic-grid cols-1" style="gap: 12px">
|
||||
<div class="cic-field">
|
||||
<label>Vendor name</label>
|
||||
<input [(ngModel)]="form.name" placeholder="e.g. Aadhaar eKYC, Payment gateway, E-mail" />
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Work — what is this vendor used for?</label>
|
||||
<textarea rows="3" [(ngModel)]="form.work" placeholder="e.g. Verify customer Aadhaar during KYC"></textarea>
|
||||
</div>
|
||||
<div class="cic-field">
|
||||
<label>Environment</label>
|
||||
<select [(ngModel)]="form.environment">
|
||||
<option value="SIMULATOR">SIMULATOR — no network call</option>
|
||||
<option value="UAT">UAT — vendor's test environment</option>
|
||||
<option value="PRODUCTION">PRODUCTION — live</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" style="font-size: 12px; margin: 12px 0 0">
|
||||
Next you set up the connection and add its APIs, each marked IN or OUT.
|
||||
</p>
|
||||
<div class="foot">
|
||||
<button class="cic-btn" (click)="adding = false">Cancel</button>
|
||||
<button class="cic-btn primary" (click)="saveNew()" [disabled]="saving">
|
||||
{{ saving ? 'Adding…' : 'Add and set up' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
|
||||
import { ApiManagementService, ApiVendor } from '../api-management.service';
|
||||
|
||||
/** Every outside API service the CBS uses: vendor, its work, its APIs (IN / OUT) and whether it is switched on. */
|
||||
@Component({
|
||||
selector: 'mifosx-api-vendors',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
],
|
||||
templateUrl: './api-vendors.component.html',
|
||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
||||
})
|
||||
export class ApiVendorsComponent implements OnInit {
|
||||
private service = inject(ApiManagementService);
|
||||
private router = inject(Router);
|
||||
|
||||
vendors: ApiVendor[] = [];
|
||||
summary: { callsToday: number; failedToday: number; perVendor: Record<number, { calls: number; failed: number }> } = {
|
||||
callsToday: 0,
|
||||
failedToday: 0,
|
||||
perVendor: {}
|
||||
};
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
info: string | null = null;
|
||||
|
||||
adding = false;
|
||||
saving = false;
|
||||
form: { name: string; work: string; environment: ApiVendor['environment'] } = this.blankForm();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
private blankForm() {
|
||||
return { name: '', work: '', environment: 'SIMULATOR' as ApiVendor['environment'] };
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.service.vendors().subscribe((v) => {
|
||||
this.vendors = v;
|
||||
this.loading = false;
|
||||
});
|
||||
this.service.summary().subscribe((s) => (this.summary = s));
|
||||
}
|
||||
|
||||
count(v: ApiVendor, direction: 'IN' | 'OUT'): number {
|
||||
return v.apis.filter((a) => a.direction === direction).length;
|
||||
}
|
||||
|
||||
get totalIn(): number {
|
||||
return this.vendors.reduce((s, v) => s + this.count(v, 'IN'), 0);
|
||||
}
|
||||
|
||||
get totalOut(): number {
|
||||
return this.vendors.reduce((s, v) => s + this.count(v, 'OUT'), 0);
|
||||
}
|
||||
|
||||
get activeVendors(): number {
|
||||
return this.vendors.filter((v) => v.active).length;
|
||||
}
|
||||
|
||||
calls(v: ApiVendor): { calls: number; failed: number } {
|
||||
return this.summary.perVendor[v.id] ?? { calls: 0, failed: 0 };
|
||||
}
|
||||
|
||||
toggle(v: ApiVendor, event: Event): void {
|
||||
const want = (event.target as HTMLInputElement).checked;
|
||||
(event.target as HTMLInputElement).checked = v.active;
|
||||
this.error = null;
|
||||
this.info = null;
|
||||
this.service.saveVendor(v.id, { active: want }).subscribe({
|
||||
next: (u) => {
|
||||
v.active = u.active;
|
||||
this.info = `${u.name} is now ${u.active ? 'active' : 'switched off'}.`;
|
||||
},
|
||||
error: (e) => (this.error = e?.error?.defaultUserMessage ?? 'Could not change the vendor.')
|
||||
});
|
||||
}
|
||||
|
||||
startAdd(): void {
|
||||
this.adding = true;
|
||||
this.form = this.blankForm();
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
saveNew(): void {
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.service.addVendor(this.form).subscribe({
|
||||
next: (v) => {
|
||||
this.saving = false;
|
||||
this.adding = false;
|
||||
this.router.navigate([
|
||||
'/api-management/vendors',
|
||||
v.id
|
||||
]);
|
||||
},
|
||||
error: (e) => {
|
||||
this.saving = false;
|
||||
this.error = e?.error?.defaultUserMessage ?? 'Could not add the vendor.';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -73,10 +73,13 @@ import { InvestmentModule } from './investment/investment.module';
|
||||
import { NpaModule } from './npa/npa.module';
|
||||
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
||||
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
||||
import { CreditBureauModule } from './credit-bureau/credit-bureau.module';
|
||||
import { ApiManagementModule } from './api-management/api-management.module';
|
||||
import { DigitalModule } from './digital/digital.module';
|
||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||
import { AccountsModule } from './accounts/accounts.module';
|
||||
import { ElectionModule } from './election/election.module';
|
||||
import { LoanEligibilityModule } from './loan-eligibility/loan-eligibility.module';
|
||||
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
|
||||
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
|
||||
import { PortalModule } from '@angular/cdk/portal';
|
||||
@@ -189,10 +192,13 @@ export function HttpLoaderFactory(http: HttpClient) {
|
||||
NpaModule,
|
||||
ReconciliationModule,
|
||||
LoanOriginationModule,
|
||||
CreditBureauModule,
|
||||
ApiManagementModule,
|
||||
DigitalModule,
|
||||
LetterMgmtModule,
|
||||
AccountsModule,
|
||||
ElectionModule,
|
||||
LoanEligibilityModule,
|
||||
SponsorDDModule,
|
||||
SponsorDDReconciliationModule,
|
||||
AppRoutingModule,
|
||||
|
||||
@@ -132,8 +132,8 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branchSelectOptions.length <= 1"
|
||||
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@for (b of branchSelectOptions; track b.id) {
|
||||
@@ -1626,8 +1626,8 @@
|
||||
<select
|
||||
class="ws-f__ctl"
|
||||
[(ngModel)]="row.br"
|
||||
[disabled]="branchSelectOptions.length <= 1"
|
||||
[title]="branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
@for (b of branchSelectOptions; track b.id) {
|
||||
|
||||
@@ -1880,6 +1880,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||
this.loadBranches();
|
||||
this.addRow();
|
||||
this.loadBranchOptions();
|
||||
this.loadDenominations();
|
||||
this.loadOpeningPosition();
|
||||
this.loadDayStatus();
|
||||
@@ -2711,11 +2712,9 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
branchNameFor(branchId: number): string {
|
||||
return (
|
||||
this.seenBranches.find((b) => b.id === branchId)?.name ??
|
||||
this.branches.find((b) => b.id === branchId)?.label ??
|
||||
`Branch ${branchId}`
|
||||
);
|
||||
const seen = this.seenBranches.find((b) => b.id === branchId);
|
||||
if (seen) return seen.name;
|
||||
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4065,11 +4064,8 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe((res: any) => {
|
||||
if (token !== this.searchToken) return; // stale result — newer search started
|
||||
const found: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||
const f = cfg.matchField;
|
||||
// SD, FD and RD share one list that returns any kind of deposit account: keep only the kind chosen in A/C TP.
|
||||
const allItems =
|
||||
cfg.depositTypeId != null ? found.filter((x: any) => x?.depositType?.id === cfg.depositTypeId) : found;
|
||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
||||
@@ -4099,18 +4095,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
this.loadingAccount = false;
|
||||
// The number may exist as another kind of account (an FD typed under SD, say): say so, so the clerk can fix A/C TP.
|
||||
const otherKind =
|
||||
cfg.depositTypeId != null
|
||||
? found.find(
|
||||
(x: any) =>
|
||||
x?.depositType?.id !== cfg.depositTypeId &&
|
||||
(String(x[f] ?? '').trim() === String(acNo).trim() || String(x[f] ?? '').endsWith(acNo))
|
||||
)
|
||||
: null;
|
||||
this.accountError = otherKind
|
||||
? `${acTyp} account not found for: ${acNo} (this number is a ${otherKind.depositType?.value ?? 'different kind of'} account - change A/C TP)`
|
||||
: `${acTyp} account not found for: ${acNo}`;
|
||||
this.accountError = 'Account not found for: ' + acNo;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4174,10 +4159,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
|
||||
* for a transaction; a Header can never receive a posting, so it should never be offered. */
|
||||
extraListQueryParams?: string;
|
||||
/** SD, FD and RD are all served by the one /savingsaccounts list, which answers with a deposit
|
||||
* account of any kind for a number. This is the depositType.id the chosen A/C type accepts
|
||||
* (100 savings, 200 fixed, 300 recurring), so a number of another kind is "not found" here. */
|
||||
depositTypeId?: number;
|
||||
} {
|
||||
const t = acTyp?.toUpperCase();
|
||||
if (t === 'LA') {
|
||||
@@ -4258,7 +4239,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/fixeddepositaccounts',
|
||||
depositTypeId: 200,
|
||||
nominee: 'FD',
|
||||
search: 'fixeddeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4276,7 +4256,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/recurringdepositaccounts',
|
||||
depositTypeId: 300,
|
||||
nominee: 'RD',
|
||||
search: 'recurringdeposit',
|
||||
siAccountType: 2,
|
||||
@@ -4294,7 +4273,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
list: '/savingsaccounts',
|
||||
detail: '/savingsaccounts',
|
||||
depositTypeId: 100,
|
||||
nominee: 'SB',
|
||||
search: 'saving',
|
||||
siAccountType: 2,
|
||||
@@ -4982,11 +4960,13 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
return Number(mine) === Number(accountOffice);
|
||||
}
|
||||
|
||||
/** What the BRANCH selector offers: the offices this login may use, plus the branches of the accounts picked here. */
|
||||
/** The bank's branches, for each row's BRANCH selector. Empty until /offices answers. */
|
||||
branchOptions: { id: number; name: string }[] = [];
|
||||
|
||||
/** What the BRANCH selector offers: the real branches, or the old fixed list while they have not loaded. */
|
||||
get branchSelectOptions(): { id: number; name: string }[] {
|
||||
const merged = this.branches.map(
|
||||
(branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? { id: branch.id, name: branch.label }
|
||||
);
|
||||
const base = this.branchOptions.length ? this.branchOptions : TransactionEntryComponent.LEGACY_BRANCHES;
|
||||
const merged = base.map((branch) => this.seenBranches.find((seen) => seen.id === branch.id) ?? branch);
|
||||
for (const seen of this.seenBranches) {
|
||||
if (!merged.some((branch) => branch.id === seen.id)) merged.push(seen);
|
||||
}
|
||||
@@ -4996,6 +4976,27 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
/** Branches learned from the accounts picked on this screen; their names are real, whatever the office list says. */
|
||||
private seenBranches: { id: number; name: string }[] = [];
|
||||
|
||||
private static readonly LEGACY_BRANCHES = [
|
||||
{ id: 1, name: '001 · Main' },
|
||||
{ id: 2, name: '002 · M.G. Road' },
|
||||
{ id: 14, name: '014 · Station Rd' },
|
||||
{ id: 27, name: '027 · Industrial' }
|
||||
];
|
||||
|
||||
/**
|
||||
* The bank's branches for the BRANCH selector. Listing offices needs the READ offices permission, which a cashier or
|
||||
* clerk may not hold, so a refusal must not raise an error popup: the selector then shows the branches this screen has
|
||||
* learned from the accounts on it (and the old fixed list until then).
|
||||
*/
|
||||
private loadBranchOptions(): void {
|
||||
this.http
|
||||
.get<any[]>('/offices', { context: new HttpContext().set(SILENT_HTTP_ERROR, true) })
|
||||
.pipe(catchError(() => of([])))
|
||||
.subscribe((offices) => {
|
||||
this.branchOptions = (offices ?? []).map((o: any) => ({ id: o.id, name: o.name }));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch an account is held at. The savings, FD and RD detail responses carry no office, so the screen fell back
|
||||
* to the client's own branch and an account opened at another branch read as the client's. The client's account list
|
||||
|
||||
@@ -356,6 +356,15 @@
|
||||
<button mat-menu-item *mifosxHasPermission="'CREATE_CURRENTACCOUNT'" [routerLink]="['current-accounts', 'create']">
|
||||
{{ 'labels.buttons.New Current Account' | translate }}
|
||||
</button>
|
||||
<!-- A Cash Credit account is a current account on a product that allows an overdraft against a sanctioned limit. -->
|
||||
<button
|
||||
mat-menu-item
|
||||
*mifosxHasPermission="'CREATE_CURRENTACCOUNT'"
|
||||
[routerLink]="['current-accounts', 'create']"
|
||||
[queryParams]="{ cc: 1 }"
|
||||
>
|
||||
New CC Account
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #Actions="matMenu">
|
||||
|
||||
@@ -846,6 +846,11 @@ export class ClientsService {
|
||||
return this.http.post('/kyc/risk-rules', rule);
|
||||
}
|
||||
|
||||
/** Ends the trial of a newly added rule; it stays in the catalog as a normal rule. */
|
||||
markClientRiskRuleNormal(ruleId: number): Observable<any> {
|
||||
return this.http.post(`/kyc/risk-rules/${ruleId}/mark-normal`, {});
|
||||
}
|
||||
|
||||
/** Fetch code values by code ID */
|
||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||
@@ -1141,8 +1146,8 @@ export class ClientsService {
|
||||
notes?: string;
|
||||
officerName?: string;
|
||||
sendAt?: string;
|
||||
}): Observable<{ queued: number; skipped: number; unreachable: number }> {
|
||||
return this.http.post<{ queued: number; skipped: number; unreachable: number }>('/kyc/notice-queue', body);
|
||||
}): Observable<{ queued: number; skipped: number }> {
|
||||
return this.http.post<{ queued: number; skipped: number }>('/kyc/notice-queue', body);
|
||||
}
|
||||
|
||||
/** Queued notices, newest first; status narrows to PENDING, SENT, FAILED or CANCELLED. */
|
||||
@@ -1156,63 +1161,9 @@ export class ClientsService {
|
||||
return this.http.post(`/kyc/notice-queue/${id}?command=${command}`, {});
|
||||
}
|
||||
|
||||
/** What each of these customers has been sent so far: the latest notice, how many, and which channels are done. */
|
||||
getKycContactSummary(clientIds: number[]): Observable<
|
||||
{
|
||||
clientId: number;
|
||||
count: number;
|
||||
lastAt: string;
|
||||
lastChannel: string;
|
||||
lastStatus: string;
|
||||
doneChannels: string[];
|
||||
balance?: number;
|
||||
hasMobile?: boolean;
|
||||
hasEmail?: boolean;
|
||||
mobileMasked?: string | null;
|
||||
emailMasked?: string | null;
|
||||
}[]
|
||||
> {
|
||||
return this.http.get<
|
||||
{
|
||||
clientId: number;
|
||||
count: number;
|
||||
lastAt: string;
|
||||
lastChannel: string;
|
||||
lastStatus: string;
|
||||
doneChannels: string[];
|
||||
hasMobile?: boolean;
|
||||
hasEmail?: boolean;
|
||||
mobileMasked?: string | null;
|
||||
emailMasked?: string | null;
|
||||
}[]
|
||||
>('/kyc/contact-audit/clients', {
|
||||
params: new HttpParams().set('ids', clientIds.join(',')),
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
/** Notices per customer (counts, outcomes, the latest), most recently contacted first, a page at a time. */
|
||||
getKycContactAuditCustomers(
|
||||
filter: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
channel?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
real?: boolean;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
} = {}
|
||||
): Observable<{ total: number; rows: any[] }> {
|
||||
return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit/customers', {
|
||||
params: this.auditParams(filter),
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
||||
});
|
||||
}
|
||||
|
||||
/** Notices per day (customers, notices, outcomes), newest day first. */
|
||||
getKycContactAuditDays(
|
||||
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string; real?: boolean } = {}
|
||||
filter: { from?: string; to?: string; channel?: string; status?: string; search?: string } = {}
|
||||
): Observable<any[]> {
|
||||
return this.http.get<any[]>('/kyc/contact-audit/days', {
|
||||
params: this.auditParams(filter),
|
||||
@@ -1228,7 +1179,6 @@ export class ClientsService {
|
||||
channel?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
real?: boolean;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
} = {}
|
||||
@@ -1239,7 +1189,7 @@ export class ClientsService {
|
||||
});
|
||||
}
|
||||
|
||||
private auditParams(filter: Record<string, string | number | boolean | undefined>): HttpParams {
|
||||
private auditParams(filter: Record<string, string | number | undefined>): HttpParams {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filter).forEach(
|
||||
([
|
||||
|
||||
@@ -59,10 +59,7 @@ export class CacheInterceptor implements HttpInterceptor {
|
||||
'/notes',
|
||||
// The ALM maturity breakdown counts deposits and loans as of the date asked and reads the bank's
|
||||
// own bucket edges from the query; a cached copy would show another date's or another setting's figures.
|
||||
'/almreturn',
|
||||
// The Approvals badge: a stream that stays open for minutes and a count that moves with every request raised or
|
||||
// actioned. A copy of either is of no use, and the stream must never be held by anything but its own caller.
|
||||
'/approvals/counts'
|
||||
'/almreturn'
|
||||
];
|
||||
|
||||
private isCacheable(url: string): boolean {
|
||||
@@ -96,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(() => {
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
/** Angular Imports */
|
||||
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ShellComponent } from '../shell/shell.component';
|
||||
|
||||
/**
|
||||
* A route strategy allowing for explicit route reuse.
|
||||
@@ -32,22 +31,7 @@ export class RouteReusableStrategy extends RouteReuseStrategy {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every feature module registers its own copy of the shell route (Route.withShell), so two pages in different modules
|
||||
* sit under two different shell routes. Treated as different, a click from one module into another destroyed the
|
||||
* shell and built a new one: the menu bar vanished until it had loaded again, and the shell's own calls (menu access,
|
||||
* notifications, business date, the approval stream) went out again on every such click. The shell is the same
|
||||
* component whichever module it wraps, so it is kept: only the page inside it changes, until a hard refresh.
|
||||
*/
|
||||
public shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
|
||||
return (
|
||||
future.routeConfig === curr.routeConfig ||
|
||||
future.data.reuse ||
|
||||
(RouteReusableStrategy.isShell(future) && RouteReusableStrategy.isShell(curr))
|
||||
);
|
||||
}
|
||||
|
||||
private static isShell(route: ActivatedRouteSnapshot): boolean {
|
||||
return route.routeConfig?.component === ShellComponent;
|
||||
return future.routeConfig === curr.routeConfig || future.data.reuse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +407,15 @@
|
||||
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a
|
||||
class="ni"
|
||||
tabindex="0"
|
||||
routerLink="/credit-bureau/dashboard"
|
||||
routerLinkActive="active"
|
||||
*mifosxHasPermission="'MENU_CREDIT_BUREAU'"
|
||||
>
|
||||
<fa-icon icon="id-card" class="ni-ico"></fa-icon>Credit Bureau
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
@@ -465,11 +474,6 @@
|
||||
*mifosxHasPermission="'MENU_TASKS'"
|
||||
>
|
||||
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
|
||||
@if (approvalTotal > 0) {
|
||||
<span class="ni-badge" title="Requests waiting for approval">{{
|
||||
approvalTotal > 99 ? '99+' : approvalTotal
|
||||
}}</span>
|
||||
}
|
||||
</a>
|
||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
|
||||
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
|
||||
@@ -513,6 +517,16 @@
|
||||
><span class="ni-short">Loans</span>
|
||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||
</a>
|
||||
<a
|
||||
class="ni"
|
||||
tabindex="0"
|
||||
routerLink="/credit-bureau/dashboard"
|
||||
routerLinkActive="active"
|
||||
*mifosxHasPermission="'MENU_CREDIT_BUREAU'"
|
||||
>
|
||||
<fa-icon icon="id-card" class="ni-ico"></fa-icon><span class="ni-full">Credit Bureau</span
|
||||
><span class="ni-short">CIC</span>
|
||||
</a>
|
||||
<a
|
||||
class="ni"
|
||||
tabindex="0"
|
||||
@@ -1232,6 +1246,9 @@
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/current']">
|
||||
<fa-icon icon="building" class="menu-item-icon"></fa-icon> Current
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/cc']">
|
||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> CC
|
||||
</button>
|
||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/fixed-deposits']">
|
||||
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Fixed Deposit
|
||||
</button>
|
||||
|
||||
@@ -1082,18 +1082,3 @@ $gray-700: #334155;
|
||||
width: $bar;
|
||||
}
|
||||
}
|
||||
|
||||
// How many requests are waiting under Approvals.
|
||||
.ni-badge {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 4px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { TenantMenuAccessService } from '../../directives/has-permission/tenant-
|
||||
|
||||
/** rxjs Imports */
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { ApprovalCountsService } from '../../tasks/approval-counts.service';
|
||||
import { map, filter } from 'rxjs/operators';
|
||||
|
||||
/** Custom Services */
|
||||
@@ -69,11 +68,6 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
private authService = inject(AuthenticationService);
|
||||
private clerkUiStateService = inject(ClerkUiStateService);
|
||||
private tenantMenuAccessService = inject(TenantMenuAccessService);
|
||||
private approvalCounts = inject(ApprovalCountsService);
|
||||
|
||||
/** Requests waiting across every Approval Center tab this user can see, shown on the Approvals menu. */
|
||||
approvalTotal = 0;
|
||||
private approvalTotalSub?: Subscription;
|
||||
|
||||
/** Subscription to breakpoint observer for handset. */
|
||||
isHandset$: Observable<boolean> = this.breakpointObserver
|
||||
@@ -81,9 +75,8 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
.pipe(map((result) => result.matches));
|
||||
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
|
||||
sidenavCollapsed = true;
|
||||
/** Progress bar mode. It starts from what is running right now: the template shows the bar for anything that is not
|
||||
* 'none', so an empty start left it running until some request happened to finish. */
|
||||
progressBarMode: string = this.progressBarService.getRequestsRunning() > 0 ? 'indeterminate' : 'none';
|
||||
/** Progress bar mode. */
|
||||
progressBarMode: string;
|
||||
/** Subscription to progress bar. */
|
||||
progressBar$: Subscription;
|
||||
/** Hides nav/sidebar for full-screen cashier routes. */
|
||||
@@ -151,13 +144,6 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
* Subscribes to progress bar to update its mode.
|
||||
*/
|
||||
ngOnInit() {
|
||||
// The Approvals badge: counts refresh every minute while the app is open, and again whenever the Approval Center opens.
|
||||
this.approvalTotalSub = this.approvalCounts.total$.subscribe((total) => {
|
||||
this.approvalTotal = total;
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
this.approvalCounts.start();
|
||||
|
||||
this.progressBar$ = this.progressBarService.updateProgressBar.subscribe((mode: string) => {
|
||||
this.progressBarMode = mode;
|
||||
this.cdr.detectChanges();
|
||||
@@ -401,8 +387,6 @@ export class ShellComponent implements OnInit, OnDestroy {
|
||||
* Unsubscribes from progress bar.
|
||||
*/
|
||||
ngOnDestroy() {
|
||||
this.approvalTotalSub?.unsubscribe();
|
||||
this.approvalCounts.stop();
|
||||
if (this.progressBar$) {
|
||||
this.progressBar$.unsubscribe();
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
<fa-icon icon="hand-holding-usd" class="ai-col-h-ic"></fa-icon>
|
||||
<span>Lending & Risk</span>
|
||||
</div>
|
||||
<button mat-menu-item class="ai-card" tabindex="0">
|
||||
<button mat-menu-item class="ai-card" routerLink="/ai/loan-eligibility" tabindex="0">
|
||||
<span class="ai-card-ic"><fa-icon icon="hand-holding-usd"></fa-icon></span>
|
||||
<span class="ai-card-body">
|
||||
<span class="ai-card-t">Loan Eligibility</span>
|
||||
@@ -239,6 +239,27 @@
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="ai-col ai-col--blue">
|
||||
<div class="ai-col-h">
|
||||
<fa-icon icon="sitemap" class="ai-col-h-ic"></fa-icon>
|
||||
<span>API Management</span>
|
||||
</div>
|
||||
<button mat-menu-item class="ai-card" routerLink="/api-management/vendors" tabindex="0">
|
||||
<span class="ai-card-ic"><fa-icon icon="cogs"></fa-icon></span>
|
||||
<span class="ai-card-body">
|
||||
<span class="ai-card-t">API Vendors & Setup</span>
|
||||
<span class="ai-card-d">Vendor, work and API set — IN / OUT (CIC bureaus, SMS, WhatsApp …)</span>
|
||||
</span>
|
||||
</button>
|
||||
<button mat-menu-item class="ai-card" routerLink="/api-management/logs" tabindex="0">
|
||||
<span class="ai-card-ic"><fa-icon icon="history"></fa-icon></span>
|
||||
<span class="ai-card-body">
|
||||
<span class="ai-card-t">API Call Logs</span>
|
||||
<span class="ai-card-d">Every IN and OUT call with request and response</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-menu-footer">
|
||||
|
||||
@@ -373,7 +373,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
||||
// ===== AI Mega Menu Panel =====
|
||||
::ng-deep .ai-menu {
|
||||
&.mat-mdc-menu-panel {
|
||||
min-width: 880px !important;
|
||||
min-width: 1080px !important;
|
||||
max-width: 96vw !important;
|
||||
max-height: 80vh;
|
||||
border-radius: 14px !important;
|
||||
@@ -520,7 +520,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
||||
// ===== Mega Layout — 4 colour-coded columns =====
|
||||
.ai-menu-cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0;
|
||||
padding: 14px 14px 6px;
|
||||
background: #f8fafc;
|
||||
@@ -567,6 +567,12 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
||||
--c-hover: #be185d;
|
||||
}
|
||||
|
||||
.ai-col--blue {
|
||||
--c-color: #0369a1;
|
||||
--c-bg: #e0f2fe;
|
||||
--c-hover: #0369a1;
|
||||
}
|
||||
|
||||
.ai-col-h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -702,7 +708,7 @@ $icon-hover-border: rgb(255 255 255 / 28%);
|
||||
}
|
||||
|
||||
// Responsive collapse
|
||||
@media (width <= 1100px) {
|
||||
@media (width <= 1300px) {
|
||||
::ng-deep .ai-menu.mat-mdc-menu-panel {
|
||||
min-width: 680px !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cic-modal-backdrop" (click)="closed.emit()">
|
||||
<div class="cic-modal" style="width: min(980px, 100%)" (click)="$event.stopPropagation()">
|
||||
@if (loading && !alert) {
|
||||
<div class="cic-empty">Loading…</div>
|
||||
}
|
||||
@if (alert) {
|
||||
@if (history.length) {
|
||||
<div class="cic-alert info" style="margin-bottom: 12px">
|
||||
You are viewing another enquiry of this customer.
|
||||
<button class="cic-btn sm" style="margin-left: 8px" (click)="back()">← Back to previous enquiry</button>
|
||||
</div>
|
||||
}
|
||||
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: flex-start">
|
||||
<div>
|
||||
<h3 style="margin-bottom: 4px">
|
||||
{{ alert.clientName || alert.customerName || 'Unknown customer' }} applied for
|
||||
{{ alert.loanType || 'a loan' }} elsewhere
|
||||
</h3>
|
||||
<div class="muted" style="font-size: 12px">
|
||||
{{ alert.bureauCode }} alert · {{ alert.alertKey }} · received {{ alert.receivedOn | slice: 0 : 16 }} via
|
||||
{{ alert.receivedVia }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<span class="cic-badge" [class]="'cic-badge ' + statusTone(alert.status)">{{ alert.status }}</span>
|
||||
<button class="cic-btn sm" (click)="closed.emit()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="cic-alert error" style="margin-top: 12px">{{ error }}</div>
|
||||
}
|
||||
@if (info) {
|
||||
<div class="cic-alert success" style="margin-top: 12px">{{ info }}</div>
|
||||
}
|
||||
|
||||
<div class="cic-grid cols-3" style="margin-top: 14px">
|
||||
<div class="cic-stat">
|
||||
<div class="k">Loan applied for</div>
|
||||
<div class="v" style="font-size: 17px">{{ alert.loanType || '—' }}</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Amount</div>
|
||||
<div class="v" style="font-size: 20px">
|
||||
{{ alert.amount !== null && alert.amount !== undefined ? '₹' + (alert.amount | number: '1.0-0') : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Enquiry date</div>
|
||||
<div class="v" style="font-size: 17px">{{ alert.enquiryDate | date: 'dd MMM yyyy' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cic-split" style="margin-top: 14px">
|
||||
<div class="cic-card" style="margin-bottom: 0">
|
||||
<h2>
|
||||
Enquiry elsewhere
|
||||
<span class="cic-card-sub">the CIC does not name the lender</span>
|
||||
</h2>
|
||||
<dl class="cic-kv" style="grid-template-columns: 140px minmax(0, 1fr)">
|
||||
<dt>Loan type</dt>
|
||||
<dd>{{ alert.loanType || '—' }}</dd>
|
||||
<dt>Amount</dt>
|
||||
<dd>
|
||||
{{ alert.amount !== null && alert.amount !== undefined ? '₹' + (alert.amount | number: '1.0-2') : '—' }}
|
||||
</dd>
|
||||
<dt>Purpose</dt>
|
||||
<dd>{{ alert.purpose || '—' }}</dd>
|
||||
<dt>Place</dt>
|
||||
<dd>{{ alert.location || '—' }}</dd>
|
||||
<dt>Enquiry date</dt>
|
||||
<dd>{{ alert.enquiryDate | date: 'dd MMM yyyy' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="cic-card" style="margin-bottom: 0">
|
||||
<h2>
|
||||
Our customer
|
||||
@if (!alert.clientId) {
|
||||
<button
|
||||
class="cic-btn sm primary"
|
||||
(click)="linkCustomer()"
|
||||
[disabled]="saving"
|
||||
*mifosxHasPermission="'UPDATE_CICALERT'"
|
||||
>
|
||||
Link to customer
|
||||
</button>
|
||||
}
|
||||
</h2>
|
||||
<dl class="cic-kv" style="grid-template-columns: 140px minmax(0, 1fr)">
|
||||
<dt>Customer</dt>
|
||||
<dd>
|
||||
@if (alert.clientId) {
|
||||
<a [routerLink]="['/clients', alert.clientId, 'general']" (click)="closed.emit()">{{
|
||||
alert.clientName
|
||||
}}</a>
|
||||
<span class="muted"> · {{ alert.clientAccountNo }}</span>
|
||||
} @else {
|
||||
<span class="tone-bad">Not matched to any customer</span>
|
||||
}
|
||||
</dd>
|
||||
<dt>Branch</dt>
|
||||
<dd>{{ alert.officeName || '—' }}</dd>
|
||||
<dt>Matched by</dt>
|
||||
<dd>{{ matchLabels[alert.matchType] || alert.matchType }}</dd>
|
||||
<dt>Name at bureau</dt>
|
||||
<dd>{{ alert.customerName || '—' }}</dd>
|
||||
<dt>PAN / Aadhaar</dt>
|
||||
<dd class="cic-mono">{{ alert.panMasked || '—' }} / {{ alert.aadhaarMasked || '—' }}</dd>
|
||||
@if (alert.customerRef) {
|
||||
<dt>Reference</dt>
|
||||
<dd class="cic-mono">{{ alert.customerRef }}</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (alert.clientId) {
|
||||
<div class="cic-card" style="margin-top: 14px; margin-bottom: 0">
|
||||
<h2>
|
||||
Customer's loans with us
|
||||
<span class="cic-card-sub">outstanding ₹{{ exposure | number: '1.0-0' }}</span>
|
||||
</h2>
|
||||
@if (alert.clientLoans?.length) {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Account</th>
|
||||
<th>Product</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Principal</th>
|
||||
<th class="num">Outstanding</th>
|
||||
<th>Disbursed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of alert.clientLoans; track l.id) {
|
||||
<tr>
|
||||
<td class="cic-mono">{{ l.accountNo }}</td>
|
||||
<td>{{ l.productName }}</td>
|
||||
<td>{{ l.status }}</td>
|
||||
<td class="num">{{ l.principal | number: '1.0-0' }}</td>
|
||||
<td class="num">{{ l.outstanding | number: '1.0-0' }}</td>
|
||||
<td>{{ l.disbursedOn | date: 'dd MMM yyyy' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="muted" style="font-size: 13px">
|
||||
No loan with us. A customer with deposits only who is borrowing elsewhere may be a cross-sell opportunity.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (alert.otherAlertsOfCustomer?.length) {
|
||||
<div class="cic-card" style="margin-top: 14px; margin-bottom: 0">
|
||||
<h2>
|
||||
All enquiries on this customer
|
||||
<span class="cic-card-sub">{{ customerEnquiries.length }} in total</span>
|
||||
</h2>
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Loan type</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (o of customerEnquiries; track o.id) {
|
||||
<tr
|
||||
class="clickable"
|
||||
[style.background]="o.id === alert.id ? '#eef4ff' : null"
|
||||
(click)="o.id !== alert.id && openOther(o.id)"
|
||||
>
|
||||
<td>{{ o.enquiryDate | date: 'dd MMM yyyy' }}</td>
|
||||
<td>
|
||||
{{ o.loanType }}
|
||||
@if (o.id === alert.id) {
|
||||
<b>(shown above)</b>
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ o.amount | number: '1.0-0' }}</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class]="'cic-badge ' + statusTone(o.status)">{{ o.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (alert.remarks || alert.actionedByName) {
|
||||
<div class="cic-alert info" style="margin-top: 14px">
|
||||
<b>{{ alert.status }}</b> by {{ alert.actionedByName }} on {{ alert.actionedOn | slice: 0 : 16 }}
|
||||
@if (alert.remarks) {
|
||||
— {{ alert.remarks }}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-container *mifosxHasPermission="'UPDATE_CICALERT'">
|
||||
@if (action) {
|
||||
<div class="cic-field" style="margin-top: 14px">
|
||||
<label>{{ action === 'ACTIONED' ? 'What was done' : 'Reason for dismissing' }}</label>
|
||||
<textarea rows="3" [(ngModel)]="remarks" maxlength="1000"></textarea>
|
||||
</div>
|
||||
}
|
||||
<div class="foot">
|
||||
@if (action) {
|
||||
<button class="cic-btn" (click)="action = null">Cancel</button>
|
||||
<button class="cic-btn primary" (click)="confirmAction()" [disabled]="saving">
|
||||
{{ action === 'ACTIONED' ? 'Save as actioned' : 'Dismiss alert' }}
|
||||
</button>
|
||||
} @else {
|
||||
@if (alert.status === 'ACTIONED' || alert.status === 'DISMISSED') {
|
||||
<button class="cic-btn" (click)="setStatus('SEEN', null)" [disabled]="saving">Reopen</button>
|
||||
}
|
||||
@if (alert.status !== 'DISMISSED') {
|
||||
<button class="cic-btn danger" (click)="action = 'DISMISSED'">Dismiss</button>
|
||||
}
|
||||
@if (alert.status !== 'ACTIONED') {
|
||||
<button class="cic-btn primary" (click)="action = 'ACTIONED'">Mark actioned</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<div style="margin-top: 12px">
|
||||
<button class="cic-btn sm" (click)="showRaw = !showRaw">{{ showRaw ? 'Hide' : 'Show' }} bureau data</button>
|
||||
@if (showRaw) {
|
||||
<pre class="cic-code" style="margin-top: 8px">{{ rawText() }}</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, OnChanges, Output, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
|
||||
import {
|
||||
CustomerSearchDialogComponent,
|
||||
CustomerSearchResult
|
||||
} from 'app/shared/customer-search-dialog/customer-search-dialog.component';
|
||||
import { CIC_MATCH_LABELS, CicAlert, CicService, cicError, cicStatusTone } from '../cic.service';
|
||||
|
||||
/**
|
||||
* One enquiry alert: which of our customers applied elsewhere (the CIC does not name the lender), for what loan and
|
||||
* how much; the customer's
|
||||
* loans with us and earlier alerts; and the follow-up (seen / actioned / dismissed, or linking an unmatched alert).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-cic-alert-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
MatDialogModule,
|
||||
HasPermissionDirective
|
||||
],
|
||||
templateUrl: './cic-alert-detail.component.html',
|
||||
styleUrls: ['../cic-shared.scss']
|
||||
})
|
||||
export class CicAlertDetailComponent implements OnChanges {
|
||||
private cic = inject(CicService);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@Input() alertId: number | null = null;
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
@Output() changed = new EventEmitter<CicAlert>();
|
||||
|
||||
alert: CicAlert | null = null;
|
||||
loading = false;
|
||||
saving = false;
|
||||
error: string | null = null;
|
||||
info: string | null = null;
|
||||
showRaw = false;
|
||||
|
||||
/** Alerts visited before the current one, so the user can go back after opening another enquiry of the customer. */
|
||||
history: number[] = [];
|
||||
private autoSeen = true;
|
||||
|
||||
action: 'ACTIONED' | 'DISMISSED' | null = null;
|
||||
remarks = '';
|
||||
|
||||
readonly matchLabels = CIC_MATCH_LABELS;
|
||||
readonly statusTone = cicStatusTone;
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.history = [];
|
||||
this.autoSeen = true;
|
||||
if (this.alertId) this.load();
|
||||
}
|
||||
|
||||
openOther(id: number): void {
|
||||
if (this.alertId) this.history.push(this.alertId);
|
||||
this.alertId = id;
|
||||
// Only the alert opened from the dashboard counts as "seen"; browsing the customer's other enquiries changes nothing.
|
||||
this.autoSeen = false;
|
||||
this.load();
|
||||
}
|
||||
|
||||
back(): void {
|
||||
const id = this.history.pop();
|
||||
if (!id) return;
|
||||
this.alertId = id;
|
||||
this.autoSeen = false;
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
if (!this.alertId) return;
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.info = null;
|
||||
this.action = null;
|
||||
this.remarks = '';
|
||||
this.cic.alert(this.alertId).subscribe({
|
||||
next: (a) => {
|
||||
this.alert = a;
|
||||
this.loading = false;
|
||||
setTimeout(() => document.querySelector('.cic-modal')?.scrollTo({ top: 0 }));
|
||||
// Opening a new alert is seeing it.
|
||||
if (this.autoSeen && a.status === 'NEW' && a.clientId) this.setStatus('SEEN', null, true);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loading = false;
|
||||
this.error = cicError(e, 'Could not load the alert.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setStatus(status: string, remarks: string | null, quiet = false): void {
|
||||
if (!this.alert) return;
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
this.cic.updateStatus(this.alert.id, status, remarks).subscribe({
|
||||
next: (a) => {
|
||||
this.saving = false;
|
||||
this.alert = a;
|
||||
this.action = null;
|
||||
this.remarks = '';
|
||||
if (!quiet) this.info = `Alert marked ${status.toLowerCase()}.`;
|
||||
this.changed.emit(a);
|
||||
},
|
||||
error: (e) => {
|
||||
this.saving = false;
|
||||
// A user without UPDATE_CICALERT can still read; the automatic "seen" is then simply skipped.
|
||||
if (!quiet) this.error = cicError(e, 'Could not update the alert.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
confirmAction(): void {
|
||||
if (!this.action) return;
|
||||
if (!this.remarks.trim()) {
|
||||
this.error =
|
||||
this.action === 'ACTIONED'
|
||||
? 'Write what was done (customer contacted, retention offer made, limit reviewed ...).'
|
||||
: 'Write why the alert is dismissed.';
|
||||
return;
|
||||
}
|
||||
this.setStatus(this.action, this.remarks.trim());
|
||||
}
|
||||
|
||||
linkCustomer(): void {
|
||||
if (!this.alert) return;
|
||||
this.dialog
|
||||
.open(CustomerSearchDialogComponent, { width: '960px', maxWidth: '95vw', data: { requireAccount: false } })
|
||||
.afterClosed()
|
||||
.subscribe((r: CustomerSearchResult | undefined) => {
|
||||
if (!r?.clientId || !this.alert) return;
|
||||
this.saving = true;
|
||||
this.cic.linkClient(this.alert.id, r.clientId).subscribe({
|
||||
next: (a) => {
|
||||
this.saving = false;
|
||||
this.alert = a;
|
||||
this.info = `Alert linked to ${a.clientName}.`;
|
||||
this.changed.emit(a);
|
||||
},
|
||||
error: (e) => {
|
||||
this.saving = false;
|
||||
this.error = cicError(e, 'Could not link the customer.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** All of the customer's enquiries, this one included, in a fixed order so the list does not reshuffle when one is opened. */
|
||||
get customerEnquiries(): CicAlert[] {
|
||||
if (!this.alert) return [];
|
||||
return [
|
||||
this.alert,
|
||||
...(this.alert.otherAlertsOfCustomer ?? [])
|
||||
].sort((x, y) => (y.enquiryDate ?? '').localeCompare(x.enquiryDate ?? '') || y.id - x.id);
|
||||
}
|
||||
|
||||
get exposure(): number {
|
||||
return (this.alert?.clientLoans ?? []).reduce((s, l) => s + Number(l.outstanding ?? 0), 0);
|
||||
}
|
||||
|
||||
rawText(): string {
|
||||
return JSON.stringify(this.alert?.raw ?? {}, null, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cic-page">
|
||||
<div class="cic-head">
|
||||
<div>
|
||||
<h1>Credit Bureau — Customer Enquiry Alerts</h1>
|
||||
<p>
|
||||
Our customers who applied for a loan at another bank or NBFC, as reported by the CIC (CIBIL, Experian, Equifax,
|
||||
CRIF): what loan and how much. The CIC does not tell which bank or lender it was.
|
||||
</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<select [(ngModel)]="days" (ngModelChange)="load()" class="cic-btn" style="padding-right: 8px">
|
||||
<option [ngValue]="7">Last 7 days</option>
|
||||
<option [ngValue]="30">Last 30 days</option>
|
||||
<option [ngValue]="90">Last 90 days</option>
|
||||
<option [ngValue]="180">Last 180 days</option>
|
||||
</select>
|
||||
<a class="cic-btn" routerLink="/credit-bureau/fetch-log">Fetch log</a>
|
||||
@if (whitelisted.length) {
|
||||
<button
|
||||
class="cic-btn primary"
|
||||
(click)="fetchNow('ALL')"
|
||||
[disabled]="fetching !== null"
|
||||
*mifosxHasPermission="'FETCH_CICALERT'"
|
||||
>
|
||||
{{ fetching === 'ALL' ? 'Fetching…' : 'Fetch alerts now' }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="cic-alert error">{{ error }}</div>
|
||||
}
|
||||
@if (info) {
|
||||
<div class="cic-alert success">{{ info }}</div>
|
||||
}
|
||||
@if (data && !whitelisted.length) {
|
||||
<div class="cic-alert warn">
|
||||
No CIC is whitelisted yet, so no alerts are being received. Switch a bureau on (CIBIL, Experian, Equifax or CRIF)
|
||||
under AI / Intelligence Modules → API Management → API Vendors & Setup.
|
||||
</div>
|
||||
}
|
||||
@if (loading && !data) {
|
||||
<div class="cic-card cic-empty">Loading…</div>
|
||||
}
|
||||
|
||||
@if (data) {
|
||||
<div class="cic-grid cols-4" style="margin-bottom: 16px">
|
||||
<div class="cic-stat">
|
||||
<div class="k">New alerts</div>
|
||||
<div class="v" [class.tone-bad]="data.newAlerts > 0">{{ data.newAlerts }}</div>
|
||||
<div class="s">not yet looked at</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Customers enquired elsewhere</div>
|
||||
<div class="v">{{ data.customersInPeriod }}</div>
|
||||
<div class="s">{{ data.alertsInPeriod }} enquiries · last {{ days }} days</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Amount applied elsewhere</div>
|
||||
<div class="v">₹{{ data.amountInPeriod | number: '1.0-0' }}</div>
|
||||
<div class="s">in total · last {{ days }} days</div>
|
||||
</div>
|
||||
<div class="cic-stat">
|
||||
<div class="k">Unmatched alerts</div>
|
||||
<div class="v" [class.tone-warn]="data.unmatchedOpen > 0">{{ data.unmatchedOpen }}</div>
|
||||
<div class="s">bureau sent, customer not found · {{ data.receivedToday }} received today</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent -->
|
||||
<div class="cic-card">
|
||||
<h2>Latest enquiries on our customers</h2>
|
||||
@if (data.recent?.length) {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enquiry date</th>
|
||||
<th>Our customer</th>
|
||||
<th>Branch</th>
|
||||
<th>Loan type</th>
|
||||
<th class="num">Amount (₹)</th>
|
||||
<th>Purpose</th>
|
||||
<th>Bureau</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (a of data.recent; track a.id) {
|
||||
<tr class="clickable" (click)="openAlertId = a.id">
|
||||
<td>{{ a.enquiryDate | date: 'dd MMM yyyy' }}</td>
|
||||
<td>
|
||||
<b>{{ a.clientName }}</b>
|
||||
<div class="muted" style="font-size: 11px">{{ a.clientAccountNo }}</div>
|
||||
</td>
|
||||
<td>{{ a.officeName }}</td>
|
||||
<td>{{ a.loanType }}</td>
|
||||
<td class="num">{{ a.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ a.purpose }}</td>
|
||||
<td>{{ a.bureauCode }}</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class]="'cic-badge ' + statusTone(a.status)">{{ a.status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cic-empty">
|
||||
No enquiry alerts yet. They appear here as soon as a CIC reports one of our customers.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="cic-split">
|
||||
<!-- Repeat customers -->
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
Customers applying elsewhere repeatedly
|
||||
<span class="cic-card-sub">2+ enquiries · last {{ days }} days</span>
|
||||
</h2>
|
||||
@if (data.repeatCustomers?.length) {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th class="num">Enquiries</th>
|
||||
<th class="num">Total applied (₹)</th>
|
||||
<th>Last</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (c of data.repeatCustomers; track c.clientId) {
|
||||
<tr>
|
||||
<td>
|
||||
<b>{{ c.clientName }}</b>
|
||||
<div class="muted" style="font-size: 11px">{{ c.accountNo }}</div>
|
||||
</td>
|
||||
<td class="num tone-bad">
|
||||
<b>{{ c.alerts }}</b>
|
||||
</td>
|
||||
<td class="num">{{ c.amount | number: '1.0-0' }}</td>
|
||||
<td>{{ c.lastEnquiryDate | date: 'dd MMM' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="cic-empty">No customer has more than one enquiry in this period.</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Trend -->
|
||||
<div class="cic-card">
|
||||
<h2>Enquiries per day <span class="cic-card-sub">last 14 days</span></h2>
|
||||
<div class="cic-trend">
|
||||
@for (p of data.trend14Days; track p.date) {
|
||||
<div
|
||||
class="col"
|
||||
[title]="(p.date | date: 'dd MMM') + ': ' + p.count + ' enquiries, ₹' + (p.amount | number: '1.0-0')"
|
||||
>
|
||||
<span class="n">{{ p.count || '' }}</span>
|
||||
<div class="bar" [style.height.%]="pct(p.count, data.trend14Days, 'count')"></div>
|
||||
<span class="d">{{ p.date | date: 'dd' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="cic-card" style="margin-bottom: 0">
|
||||
<h2>By loan type</h2>
|
||||
@for (g of data.byLoanType; track g.label) {
|
||||
<div style="margin-bottom: 10px">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 4px">
|
||||
<span>{{ g.label }}</span>
|
||||
<span class="muted">{{ g.count }} · ₹{{ g.amount | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="cic-bar"><div [style.width.%]="pct(g.count, data.byLoanType, 'count')"></div></div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="cic-empty">No data.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bureau fetch status -->
|
||||
<div class="cic-card" style="margin-top: 16px">
|
||||
<h2>
|
||||
Alert feed from each CIC
|
||||
<a class="cic-btn sm" routerLink="/credit-bureau/fetch-log">Fetch log</a>
|
||||
</h2>
|
||||
<div class="cic-grid cols-4">
|
||||
@for (b of data.bureaus; track b.id) {
|
||||
<div class="cic-stat">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; gap: 6px">
|
||||
<b>{{ b.code }}</b>
|
||||
<span class="cic-badge" [class.good]="b.whitelisted">{{ b.whitelisted ? 'Whitelisted' : 'Off' }}</span>
|
||||
</div>
|
||||
<div class="s" style="margin-top: 6px">
|
||||
{{ b.environment }} · {{ b.autoFetch ? 'auto every ' + b.intervalMin + ' min' : 'manual fetch only' }}
|
||||
</div>
|
||||
<div class="s">
|
||||
Last fetch:
|
||||
@if (b.lastFetchOn) {
|
||||
<span class="cic-badge" [class]="'cic-badge ' + fetchTone(b.lastFetchStatus)">{{
|
||||
b.lastFetchStatus
|
||||
}}</span>
|
||||
{{ b.lastFetchOn | slice: 0 : 16 }}
|
||||
} @else {
|
||||
never
|
||||
}
|
||||
</div>
|
||||
@if (b.lastFetchMessage) {
|
||||
<div class="s" style="font-size: 11px">{{ b.lastFetchMessage }}</div>
|
||||
}
|
||||
<div class="s" style="font-size: 11px; margin-top: 4px">
|
||||
@if (b.environment === 'SIMULATOR') {
|
||||
Simulator: no call goes to {{ b.code }}; the alerts are made up by the CBS for testing.
|
||||
} @else {
|
||||
Calls {{ b.code }} {{ b.environment }} with the API set up under API Management.
|
||||
}
|
||||
</div>
|
||||
@if (b.whitelisted) {
|
||||
<button
|
||||
class="cic-btn sm"
|
||||
style="margin-top: 8px"
|
||||
(click)="fetchNow(b.id)"
|
||||
[disabled]="fetching !== null"
|
||||
*mifosxHasPermission="'FETCH_CICALERT'"
|
||||
>
|
||||
{{ fetching === b.id ? 'Fetching…' : 'Fetch now' }}
|
||||
</button>
|
||||
}
|
||||
<a class="cic-btn sm" style="margin-top: 8px; margin-left: 4px" [routerLink]="setupRoute(b.code)">Set up</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (openAlertId) {
|
||||
<mifosx-cic-alert-detail
|
||||
[alertId]="openAlertId"
|
||||
(closed)="openAlertId = null"
|
||||
(changed)="onChanged()"
|
||||
></mifosx-cic-alert-detail>
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { ApiManagementService } from 'app/api-management/api-management.service';
|
||||
import { HasPermissionDirective } from 'app/directives/has-permission/has-permission.directive';
|
||||
import { CicAlertDetailComponent } from '../cic-alert-detail/cic-alert-detail.component';
|
||||
import { CicService, cicError, cicStatusTone } from '../cic.service';
|
||||
|
||||
/**
|
||||
* Credit Bureau dashboard: our customers who applied for a loan at another bank / NBFC, as reported by the CICs -
|
||||
* who, what loan and how much (the CIC does not name the lender) - with the customers applying elsewhere repeatedly,
|
||||
* the loan types, the trend, and the fetch status of each bureau. Each bureau is set up under API Management → API
|
||||
* Vendors & Setup.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-cic-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
HasPermissionDirective,
|
||||
CicAlertDetailComponent
|
||||
],
|
||||
templateUrl: './cic-dashboard.component.html',
|
||||
styleUrls: ['../cic-shared.scss']
|
||||
})
|
||||
export class CicDashboardComponent implements OnInit {
|
||||
private cic = inject(CicService);
|
||||
private apis = inject(ApiManagementService);
|
||||
|
||||
/** Each bureau is set up as a vendor under API Management (connection, whitelist, request / response mapping). */
|
||||
setupRoute(code: string): any[] {
|
||||
const v = this.apis.vendorByCode(code);
|
||||
return v ? [
|
||||
'/api-management/vendors',
|
||||
v.id
|
||||
] : ['/api-management/vendors'];
|
||||
}
|
||||
|
||||
days = 30;
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
info: string | null = null;
|
||||
data: any = null;
|
||||
fetching: number | 'ALL' | null = null;
|
||||
openAlertId: number | null = null;
|
||||
|
||||
readonly statusTone = cicStatusTone;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.cic.dashboard(this.days).subscribe({
|
||||
next: (d) => {
|
||||
this.data = d;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (e) => {
|
||||
this.error = cicError(e, 'Could not load the credit bureau dashboard.');
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get whitelisted(): any[] {
|
||||
return (this.data?.bureaus ?? []).filter((b: any) => b.whitelisted);
|
||||
}
|
||||
|
||||
/** Fetch now from one bureau, or from every whitelisted bureau in turn. */
|
||||
fetchNow(bureauId: number | 'ALL'): void {
|
||||
const ids = bureauId === 'ALL' ? this.whitelisted.map((b) => b.id as number) : [bureauId];
|
||||
if (!ids.length) return;
|
||||
this.fetching = bureauId;
|
||||
this.error = null;
|
||||
this.info = null;
|
||||
const messages: string[] = [];
|
||||
const next = (i: number) => {
|
||||
if (i >= ids.length) {
|
||||
this.fetching = null;
|
||||
this.info = messages.join(' · ');
|
||||
this.load();
|
||||
return;
|
||||
}
|
||||
this.cic.fetch(ids[i]).subscribe({
|
||||
next: (run) => {
|
||||
messages.push(
|
||||
`${run.bureauCode}: ${run.status === 'SUCCESS' ? run.newAlerts + ' new alert(s)' : 'failed - ' + run.message}`
|
||||
);
|
||||
next(i + 1);
|
||||
},
|
||||
error: (e) => {
|
||||
messages.push(cicError(e, 'Fetch failed.'));
|
||||
next(i + 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
next(0);
|
||||
}
|
||||
|
||||
max(list: any[] | undefined, key: string): number {
|
||||
return Math.max(1, ...(list ?? []).map((x) => Number(x[key] ?? 0)));
|
||||
}
|
||||
|
||||
pct(value: any, list: any[] | undefined, key: string): number {
|
||||
return Math.round((Number(value ?? 0) / this.max(list, key)) * 100);
|
||||
}
|
||||
|
||||
onChanged(): void {
|
||||
this.load();
|
||||
}
|
||||
|
||||
fetchTone(status?: string): string {
|
||||
return status === 'SUCCESS' ? 'good' : status === 'FAILED' ? 'bad' : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="cic-page">
|
||||
<div class="cic-head">
|
||||
<div>
|
||||
<h1>Fetch Log</h1>
|
||||
<p>Every time the CBS asked a CIC for enquiry alerts: when, how many came, how many were our customers.</p>
|
||||
</div>
|
||||
<div class="cic-actions">
|
||||
<a class="cic-btn" routerLink="/credit-bureau/dashboard">← Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="cic-alert error">{{ error }}</div>
|
||||
}
|
||||
|
||||
<div class="cic-card">
|
||||
<h2>
|
||||
Fetches
|
||||
<span class="cic-actions">
|
||||
<select [(ngModel)]="bureauId" (ngModelChange)="filterChanged()" class="cic-btn sm">
|
||||
<option value="">All bureaus</option>
|
||||
@for (b of bureaus; track b.id) {
|
||||
<option [value]="b.id">{{ b.code }}</option>
|
||||
}
|
||||
</select>
|
||||
<button class="cic-btn sm" (click)="load()">Refresh</button>
|
||||
</span>
|
||||
</h2>
|
||||
@if (loading && !runs.length) {
|
||||
<div class="cic-empty">Loading…</div>
|
||||
} @else if (!runs.length) {
|
||||
<div class="cic-empty">No fetch has run yet.</div>
|
||||
} @else {
|
||||
<div class="cic-table-wrap">
|
||||
<table class="cic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Bureau</th>
|
||||
<th>How</th>
|
||||
<th>Window</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Received</th>
|
||||
<th class="num">New</th>
|
||||
<th class="num">Matched</th>
|
||||
<th class="num">Unmatched</th>
|
||||
<th class="num">Already had</th>
|
||||
<th class="num">Own bank</th>
|
||||
<th>By</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of runs; track r.id) {
|
||||
<tr class="clickable" (click)="view(r)">
|
||||
<td class="cic-mono">{{ r.runRef }}</td>
|
||||
<td>{{ r.bureauCode }}</td>
|
||||
<td>{{ r.trigger }}</td>
|
||||
<td>{{ r.fromDate | date: 'dd MMM' }} – {{ r.toDate | date: 'dd MMM yy' }}</td>
|
||||
<td>
|
||||
<span class="cic-badge" [class]="'cic-badge ' + tone(r.status)">{{ r.status }}</span>
|
||||
</td>
|
||||
<td class="num">{{ r.received }}</td>
|
||||
<td class="num">
|
||||
<b>{{ r.newAlerts }}</b>
|
||||
</td>
|
||||
<td class="num">{{ r.matched }}</td>
|
||||
<td class="num">{{ r.unmatched }}</td>
|
||||
<td class="num">{{ r.duplicates }}</td>
|
||||
<td class="num">{{ r.ownBank }}</td>
|
||||
<td>{{ r.triggeredByName }}</td>
|
||||
<td>{{ r.startedOn | slice: 0 : 16 }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cic-pager">
|
||||
<span>{{ offset + 1 }}–{{ offset + runs.length }} of {{ total }}</span>
|
||||
<span class="cic-actions">
|
||||
<button class="cic-btn sm" (click)="page(-1)" [disabled]="offset === 0">Previous</button>
|
||||
<button class="cic-btn sm" (click)="page(1)" [disabled]="offset + limit >= total">Next</button>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (viewing) {
|
||||
<div class="cic-modal-backdrop" (click)="viewing = null">
|
||||
<div class="cic-modal" style="width: min(1000px, 100%)" (click)="$event.stopPropagation()">
|
||||
<h3>{{ viewing.runRef }} · {{ viewing.bureauCode }} · {{ viewing.status }}</h3>
|
||||
<div
|
||||
class="cic-alert"
|
||||
[class.success]="viewing.status === 'SUCCESS'"
|
||||
[class.error]="viewing.status !== 'SUCCESS'"
|
||||
>
|
||||
{{ viewing.message }}
|
||||
@if (viewing.httpStatus) {
|
||||
(HTTP {{ viewing.httpStatus }}, {{ viewing.durationMs }} ms)
|
||||
}
|
||||
</div>
|
||||
<div class="cic-split">
|
||||
<div>
|
||||
<h4 style="margin: 0 0 6px; font-size: 13px">Request</h4>
|
||||
<pre class="cic-code">{{ pretty(viewing.request) || '—' }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style="margin: 0 0 6px; font-size: 13px">Response</h4>
|
||||
<pre class="cic-code">{{ pretty(viewing.response) || '—' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<button class="cic-btn" (click)="viewing = null">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { CicBureau, CicFetchRun, CicService, cicError } from '../cic.service';
|
||||
|
||||
/** Log of every alert fetch from the CICs: when, how many alerts, how many matched, and the request / response. */
|
||||
@Component({
|
||||
selector: 'mifosx-cic-fetch-log',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule
|
||||
],
|
||||
templateUrl: './cic-fetch-log.component.html',
|
||||
styleUrls: ['../cic-shared.scss']
|
||||
})
|
||||
export class CicFetchLogComponent implements OnInit {
|
||||
private cic = inject(CicService);
|
||||
|
||||
bureaus: CicBureau[] = [];
|
||||
runs: CicFetchRun[] = [];
|
||||
total = 0;
|
||||
offset = 0;
|
||||
readonly limit = 20;
|
||||
bureauId = '';
|
||||
viewing: CicFetchRun | null = null;
|
||||
loading = false;
|
||||
error: string | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.cic.bureaus().subscribe({ next: (b) => (this.bureaus = b), error: () => (this.bureaus = []) });
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.cic.fetchRuns({ bureauId: this.bureauId, offset: this.offset, limit: this.limit }).subscribe({
|
||||
next: (p) => {
|
||||
this.runs = p.pageItems;
|
||||
this.total = p.totalFilteredRecords;
|
||||
this.loading = false;
|
||||
},
|
||||
error: (e) => {
|
||||
this.error = cicError(e, 'Could not load the fetch log.');
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
filterChanged(): void {
|
||||
this.offset = 0;
|
||||
this.load();
|
||||
}
|
||||
|
||||
page(delta: number): void {
|
||||
this.offset = Math.max(0, this.offset + delta * this.limit);
|
||||
this.load();
|
||||
}
|
||||
|
||||
view(r: CicFetchRun): void {
|
||||
this.cic.fetchRun(r.id).subscribe({
|
||||
next: (full) => (this.viewing = full),
|
||||
error: (e) => (this.error = cicError(e))
|
||||
});
|
||||
}
|
||||
|
||||
pretty(text?: string | null): string {
|
||||
if (!text) return '';
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
tone(status: string): string {
|
||||
return status === 'SUCCESS' ? 'good' : status === 'FAILED' ? 'bad' : 'warn';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
/* Shared look for the Credit Bureau (CIC) screens. */
|
||||
|
||||
:host {
|
||||
--cic-ink: #0f172a;
|
||||
--cic-muted: #64748b;
|
||||
--cic-line: #e2e8f0;
|
||||
--cic-soft: #f8fafc;
|
||||
--cic-brand: #1d4ed8;
|
||||
--cic-brand-soft: #eff6ff;
|
||||
--cic-good: #047857;
|
||||
--cic-good-soft: #ecfdf5;
|
||||
--cic-ok: #0369a1;
|
||||
--cic-ok-soft: #f0f9ff;
|
||||
--cic-warn: #b45309;
|
||||
--cic-warn-soft: #fffbeb;
|
||||
--cic-bad: #b91c1c;
|
||||
--cic-bad-soft: #fef2f2;
|
||||
|
||||
display: block;
|
||||
color: var(--cic-ink);
|
||||
}
|
||||
|
||||
.cic-page {
|
||||
padding: 20px 24px 40px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cic-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--cic-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cic-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--cic-line);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 4%);
|
||||
|
||||
> h2,
|
||||
.cic-card-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cic-card-sub {
|
||||
color: var(--cic-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
|
||||
&.cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
&.cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
&.cols-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (width <= 960px) {
|
||||
&.cols-3,
|
||||
&.cols-4 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
&.cols-2,
|
||||
&.cols-3,
|
||||
&.cols-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cic-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
gap: 16px;
|
||||
|
||||
@media (width <= 1100px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-stat {
|
||||
background: #fff;
|
||||
border: 1px solid var(--cic-line);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
|
||||
.k {
|
||||
font-size: 12px;
|
||||
color: var(--cic-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.v {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.s {
|
||||
font-size: 12px;
|
||||
color: var(--cic-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
|
||||
label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--cic-muted);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
color: var(--cic-ink);
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--cic-brand);
|
||||
box-shadow: 0 0 0 3px rgb(29 78 216 / 12%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--cic-soft);
|
||||
color: var(--cic-muted);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 90px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-mono,
|
||||
textarea.cic-mono {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.cic-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--cic-line);
|
||||
background: #fff;
|
||||
color: var(--cic-ink);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--cic-soft);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: var(--cic-brand);
|
||||
border-color: var(--cic-brand);
|
||||
color: #fff;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #1e40af;
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: var(--cic-bad);
|
||||
border-color: #fecaca;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--cic-bad-soft);
|
||||
}
|
||||
}
|
||||
|
||||
&.sm {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
background: var(--cic-soft);
|
||||
color: var(--cic-muted);
|
||||
border: 1px solid var(--cic-line);
|
||||
white-space: nowrap;
|
||||
|
||||
&.good {
|
||||
background: var(--cic-good-soft);
|
||||
color: var(--cic-good);
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
|
||||
&.ok {
|
||||
background: var(--cic-ok-soft);
|
||||
color: var(--cic-ok);
|
||||
border-color: #bae6fd;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: var(--cic-warn-soft);
|
||||
color: var(--cic-warn);
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
&.bad {
|
||||
background: var(--cic-bad-soft);
|
||||
color: var(--cic-bad);
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
&.brand {
|
||||
background: var(--cic-brand-soft);
|
||||
color: var(--cic-brand);
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-alert {
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid;
|
||||
|
||||
&.error {
|
||||
background: var(--cic-bad-soft);
|
||||
border-color: #fecaca;
|
||||
color: var(--cic-bad);
|
||||
}
|
||||
|
||||
&.success {
|
||||
background: var(--cic-good-soft);
|
||||
border-color: #a7f3d0;
|
||||
color: var(--cic-good);
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: var(--cic-brand-soft);
|
||||
border-color: #bfdbfe;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: var(--cic-warn-soft);
|
||||
border-color: #fde68a;
|
||||
color: var(--cic-warn);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--cic-line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.cic-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--cic-muted);
|
||||
font-weight: 700;
|
||||
background: var(--cic-soft);
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid var(--cic-line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
td input,
|
||||
td select {
|
||||
width: 100%;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 5px 7px;
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-empty {
|
||||
text-align: center;
|
||||
color: var(--cic-muted);
|
||||
padding: 28px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cic-pager {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--cic-muted);
|
||||
}
|
||||
|
||||
.cic-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex: none;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #cbd5e1;
|
||||
border-radius: 999px;
|
||||
transition: background 0.15s;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.15s;
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + span {
|
||||
background: var(--cic-good);
|
||||
}
|
||||
|
||||
input:checked + span::before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
input:disabled + span {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--cic-line);
|
||||
margin-bottom: 14px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--cic-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
|
||||
&.active {
|
||||
color: var(--cic-brand);
|
||||
border-bottom-color: var(--cic-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cic-kv {
|
||||
display: grid;
|
||||
grid-template-columns: 170px minmax(0, 1fr);
|
||||
gap: 7px 12px;
|
||||
font-size: 13px;
|
||||
|
||||
dt {
|
||||
color: var(--cic-muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-code {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cic-trend {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
height: 180px;
|
||||
padding-top: 6px;
|
||||
|
||||
.col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 100%;
|
||||
max-width: 28px;
|
||||
min-height: 2px;
|
||||
background: var(--cic-brand);
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.n {
|
||||
font-size: 10px;
|
||||
color: var(--cic-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.d {
|
||||
font-size: 10px;
|
||||
color: var(--cic-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(15 23 42 / 45%);
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cic-modal {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
width: min(640px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
padding: 20px 22px;
|
||||
box-shadow: 0 20px 50px rgb(15 23 42 / 25%);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.cic-bar {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
|
||||
> div {
|
||||
height: 100%;
|
||||
background: var(--cic-brand);
|
||||
border-radius: 999px;
|
||||
}
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--cic-muted);
|
||||
}
|
||||
|
||||
.tone-good {
|
||||
color: var(--cic-good);
|
||||
}
|
||||
|
||||
.tone-ok {
|
||||
color: var(--cic-ok);
|
||||
}
|
||||
|
||||
.tone-warn {
|
||||
color: var(--cic-warn);
|
||||
}
|
||||
|
||||
.tone-bad {
|
||||
color: var(--cic-bad);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||
|
||||
/** A credit bureau (CIC) as configured for the bank. Never carries the secret itself. */
|
||||
export interface CicBureau {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
whitelisted: boolean;
|
||||
isDefault: boolean;
|
||||
environment: 'SIMULATOR' | 'UAT' | 'PRODUCTION';
|
||||
baseUrl?: string;
|
||||
memberCode?: string;
|
||||
authType: string;
|
||||
username?: string;
|
||||
apiKeyHeader?: string;
|
||||
hasSecret: boolean;
|
||||
tokenUrl?: string;
|
||||
clientId?: string;
|
||||
timeoutMs: number;
|
||||
alertAutoFetch: boolean;
|
||||
alertFetchIntervalMin: number;
|
||||
alertLookbackDays: number;
|
||||
lastFetchOn?: string;
|
||||
lastFetchStatus?: string;
|
||||
lastFetchMessage?: string;
|
||||
lastTestStatus?: string;
|
||||
lastTestMessage?: string;
|
||||
lastTestedOn?: string;
|
||||
updatedOn?: string;
|
||||
activeEndpoints: number;
|
||||
alertsLast30Days: number;
|
||||
newAlerts: number;
|
||||
}
|
||||
|
||||
export interface CicMapping {
|
||||
id?: number;
|
||||
sourceField: string | null;
|
||||
targetField: string;
|
||||
transform: string;
|
||||
defaultValue: string | null;
|
||||
mandatory: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface CicEndpoint {
|
||||
bureauId: number;
|
||||
endpointType: string;
|
||||
exists: boolean;
|
||||
id?: number;
|
||||
httpMethod: string;
|
||||
path?: string;
|
||||
contentType: string;
|
||||
headersJson: string;
|
||||
baseBody: string;
|
||||
isActive: boolean;
|
||||
requestMappings: CicMapping[];
|
||||
responseMappings: CicMapping[];
|
||||
}
|
||||
|
||||
export interface CicCatalog {
|
||||
environments: string[];
|
||||
authTypes: string[];
|
||||
endpointTypes: string[];
|
||||
httpMethods: string[];
|
||||
cbsFields: Record<string, string>;
|
||||
resultFields: Record<string, string>;
|
||||
rootFields: string[];
|
||||
transforms: Record<string, string>;
|
||||
alertStatuses: string[];
|
||||
matchTypes: string[];
|
||||
}
|
||||
|
||||
/** One enquiry made on our customer at another lender, as reported by the CIC. */
|
||||
export interface CicAlert {
|
||||
id: number;
|
||||
bureauId: number;
|
||||
bureauCode: string;
|
||||
alertKey: string;
|
||||
clientId?: number;
|
||||
clientName?: string;
|
||||
clientAccountNo?: string;
|
||||
officeId?: number;
|
||||
officeName?: string;
|
||||
matchType: 'PAN' | 'AADHAAR' | 'CUSTOMER_REF' | 'MANUAL' | 'NONE';
|
||||
customerName?: string;
|
||||
panMasked?: string;
|
||||
aadhaarMasked?: string;
|
||||
customerRef?: string;
|
||||
enquiryDate?: string;
|
||||
loanType?: string;
|
||||
amount?: number;
|
||||
purpose?: string;
|
||||
location?: string;
|
||||
status: 'NEW' | 'SEEN' | 'ACTIONED' | 'DISMISSED';
|
||||
remarks?: string;
|
||||
actionedByName?: string;
|
||||
actionedOn?: string;
|
||||
receivedVia?: string;
|
||||
receivedOn?: string;
|
||||
raw?: any;
|
||||
fetchRunId?: number;
|
||||
otherAlertsOfCustomer?: CicAlert[];
|
||||
clientLoans?: {
|
||||
id: number;
|
||||
accountNo: string;
|
||||
productName: string;
|
||||
status: string;
|
||||
principal: number;
|
||||
outstanding: number;
|
||||
disbursedOn?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface CicFetchRun {
|
||||
id: number;
|
||||
runRef: string;
|
||||
bureauId: number;
|
||||
bureauCode: string;
|
||||
trigger: 'MANUAL' | 'SCHEDULED';
|
||||
environment?: string;
|
||||
fromDate?: string;
|
||||
toDate?: string;
|
||||
status: 'RUNNING' | 'SUCCESS' | 'FAILED';
|
||||
httpStatus?: number;
|
||||
received: number;
|
||||
newAlerts: number;
|
||||
matched: number;
|
||||
unmatched: number;
|
||||
duplicates: number;
|
||||
ownBank: number;
|
||||
message?: string;
|
||||
durationMs?: number;
|
||||
triggeredByName?: string;
|
||||
startedOn: string;
|
||||
request?: string;
|
||||
response?: string;
|
||||
}
|
||||
|
||||
export interface CicPage<T> {
|
||||
totalFilteredRecords: number;
|
||||
pageItems: T[];
|
||||
}
|
||||
|
||||
export const CIC_MATCH_LABELS: Record<string, string> = {
|
||||
PAN: 'PAN',
|
||||
AADHAAR: 'Aadhaar',
|
||||
CUSTOMER_REF: 'Customer ref',
|
||||
MANUAL: 'Linked manually',
|
||||
NONE: 'Not matched'
|
||||
};
|
||||
|
||||
export function cicStatusTone(status: string): string {
|
||||
return status === 'NEW' ? 'bad' : status === 'SEEN' ? 'warn' : status === 'ACTIONED' ? 'good' : '';
|
||||
}
|
||||
|
||||
/** The server's own message for a failed call. */
|
||||
export function cicError(err: any, fallback = 'Something went wrong. Please try again.'): string {
|
||||
return (
|
||||
err?.error?.errors?.[0]?.defaultUserMessage ??
|
||||
err?.error?.defaultUserMessage ??
|
||||
err?.error?.message ??
|
||||
err?.message ??
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CicService {
|
||||
private http = inject(HttpClient);
|
||||
private base = '/cic';
|
||||
/** Errors are shown inline on these screens, so the global alert is suppressed. */
|
||||
private silent = { context: new HttpContext().set(SILENT_HTTP_ERROR, true) };
|
||||
|
||||
catalog(): Observable<CicCatalog> {
|
||||
return this.http.get<CicCatalog>(`${this.base}/catalog`, this.silent);
|
||||
}
|
||||
|
||||
bureaus(whitelistedOnly = false): Observable<CicBureau[]> {
|
||||
return this.http.get<CicBureau[]>(`${this.base}/bureaus`, {
|
||||
...this.silent,
|
||||
params: new HttpParams().set('whitelistedOnly', String(whitelistedOnly))
|
||||
});
|
||||
}
|
||||
|
||||
updateBureau(id: number, body: any): Observable<CicBureau> {
|
||||
return this.http.put<CicBureau>(`${this.base}/bureaus/${id}`, body, this.silent);
|
||||
}
|
||||
|
||||
setWhitelisted(id: number, whitelisted: boolean): Observable<CicBureau> {
|
||||
return this.http.put<CicBureau>(`${this.base}/bureaus/${id}/whitelist`, { whitelisted }, this.silent);
|
||||
}
|
||||
|
||||
testConnection(id: number): Observable<{ status: string; message: string }> {
|
||||
return this.http.post<{ status: string; message: string }>(
|
||||
`${this.base}/bureaus/${id}/test-connection`,
|
||||
{},
|
||||
this.silent
|
||||
);
|
||||
}
|
||||
|
||||
endpoint(bureauId: number, type: string): Observable<CicEndpoint> {
|
||||
return this.http.get<CicEndpoint>(`${this.base}/bureaus/${bureauId}/endpoints/${type}`, this.silent);
|
||||
}
|
||||
|
||||
saveEndpoint(bureauId: number, type: string, body: any): Observable<CicEndpoint> {
|
||||
return this.http.put<CicEndpoint>(`${this.base}/bureaus/${bureauId}/endpoints/${type}`, body, this.silent);
|
||||
}
|
||||
|
||||
preview(bureauId: number, body: { fromDate?: string; toDate?: string }): Observable<any> {
|
||||
return this.http.post<any>(`${this.base}/bureaus/${bureauId}/preview`, body, this.silent);
|
||||
}
|
||||
|
||||
parseTest(bureauId: number, content: string | null): Observable<any> {
|
||||
return this.http.post<any>(`${this.base}/bureaus/${bureauId}/parse-test`, { content }, this.silent);
|
||||
}
|
||||
|
||||
fetch(bureauId: number, body: { fromDate?: string; toDate?: string } = {}): Observable<CicFetchRun> {
|
||||
return this.http.post<CicFetchRun>(`${this.base}/bureaus/${bureauId}/fetch`, body, this.silent);
|
||||
}
|
||||
|
||||
fetchRuns(filter: Record<string, any>): Observable<CicPage<CicFetchRun>> {
|
||||
return this.http.get<CicPage<CicFetchRun>>(`${this.base}/fetch-runs`, {
|
||||
...this.silent,
|
||||
params: this.params(filter)
|
||||
});
|
||||
}
|
||||
|
||||
fetchRun(id: number): Observable<CicFetchRun> {
|
||||
return this.http.get<CicFetchRun>(`${this.base}/fetch-runs/${id}`, this.silent);
|
||||
}
|
||||
|
||||
dashboard(days = 30): Observable<any> {
|
||||
return this.http.get<any>(`${this.base}/dashboard`, {
|
||||
...this.silent,
|
||||
params: new HttpParams().set('days', String(days))
|
||||
});
|
||||
}
|
||||
|
||||
alert(id: number): Observable<CicAlert> {
|
||||
return this.http.get<CicAlert>(`${this.base}/alerts/${id}`, this.silent);
|
||||
}
|
||||
|
||||
updateStatus(id: number, status: string, remarks?: string | null): Observable<CicAlert> {
|
||||
return this.http.put<CicAlert>(`${this.base}/alerts/${id}/status`, { status, remarks }, this.silent);
|
||||
}
|
||||
|
||||
linkClient(id: number, clientId: number): Observable<CicAlert> {
|
||||
return this.http.put<CicAlert>(`${this.base}/alerts/${id}/client`, { clientId }, this.silent);
|
||||
}
|
||||
|
||||
private params(filter: Record<string, any>): HttpParams {
|
||||
let params = new HttpParams();
|
||||
Object.entries(filter).forEach(
|
||||
([
|
||||
k,
|
||||
v
|
||||
]) => {
|
||||
if (v !== null && v !== undefined && v !== '') params = params.set(k, String(v));
|
||||
}
|
||||
);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { Route } from '../core/route/route.service';
|
||||
import { CicDashboardComponent } from './cic-dashboard/cic-dashboard.component';
|
||||
import { CicFetchLogComponent } from './cic-fetch-log/cic-fetch-log.component';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'credit-bureau',
|
||||
data: { title: 'Credit Bureau', breadcrumb: 'Credit Bureau' },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'dashboard',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
component: CicDashboardComponent,
|
||||
data: { title: 'Credit Bureau Dashboard', breadcrumb: 'Dashboard' }
|
||||
},
|
||||
{
|
||||
path: 'fetch-log',
|
||||
component: CicFetchLogComponent,
|
||||
data: { title: 'Fetch Log', breadcrumb: 'Fetch Log' }
|
||||
},
|
||||
// The bureaus are set up, whitelisted and mapped under API Management like every other vendor.
|
||||
{ path: 'bureaus', redirectTo: '/api-management/vendors', pathMatch: 'full' },
|
||||
{ path: 'api-mapping', redirectTo: '/api-management/vendors', pathMatch: 'full' }
|
||||
]
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class CreditBureauRoutingModule {}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { CreditBureauRoutingModule } from './credit-bureau-routing.module';
|
||||
import { CicDashboardComponent } from './cic-dashboard/cic-dashboard.component';
|
||||
import { CicFetchLogComponent } from './cic-fetch-log/cic-fetch-log.component';
|
||||
import { CicAlertDetailComponent } from './cic-alert-detail/cic-alert-detail.component';
|
||||
import { CicService } from './cic.service';
|
||||
import { CicStaticService } from './cic-static.service';
|
||||
|
||||
/**
|
||||
* CIC / credit bureau (CIBIL, Experian, Equifax, CRIF) - its own top-level menu: enquiry alerts on our customers from
|
||||
* other lenders and the fetch log. Each bureau's connection, whitelist and API mapping live under API Management.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
SharedModule,
|
||||
CreditBureauRoutingModule,
|
||||
CicDashboardComponent,
|
||||
CicFetchLogComponent,
|
||||
CicAlertDetailComponent
|
||||
],
|
||||
providers: [
|
||||
// Static demo data, no backend call. Remove this line to use the real /v1/cic API again.
|
||||
{ provide: CicService, useClass: CicStaticService }
|
||||
]
|
||||
})
|
||||
export class CreditBureauModule {}
|
||||
+12
@@ -13,6 +13,18 @@
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<h3>Account Details</h3>
|
||||
</div>
|
||||
@if (isCcRequest) {
|
||||
<p class="sad-cc-note">
|
||||
@if (ccProductsMissing) {
|
||||
<strong>No CC product yet.</strong> A Cash Credit account is opened on a current product that allows an
|
||||
overdraft against a sanctioned limit. Create or edit a current product with “Is Overdraft Allowed” switched
|
||||
on, then come back here.
|
||||
} @else {
|
||||
Opening a <strong>Cash Credit (CC)</strong> account: only products that allow an overdraft are listed. The
|
||||
sanctioned limit comes from the product.
|
||||
}
|
||||
</p>
|
||||
}
|
||||
<div class="sad-grid">
|
||||
<mat-form-field appearance="outline" class="sad-field">
|
||||
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
|
||||
|
||||
+13
@@ -1735,3 +1735,16 @@ $ni-bg: #f9fafb;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shown when the screen was opened from "New CC Account". */
|
||||
.sad-cc-note {
|
||||
margin: 0 0 14px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-left: 4px solid #1d4ed8;
|
||||
border-radius: 8px;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
+32
@@ -31,6 +31,8 @@ import { AddRelatedClientDialogComponent } from './add-related-client-dialog/add
|
||||
import { AlertService } from 'app/core/alert/alert.service';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { ProductsService } from 'app/products/products.service';
|
||||
|
||||
/**
|
||||
* Current Account Details Step
|
||||
@@ -58,6 +60,13 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
private alertService = inject(AlertService);
|
||||
private dateUtils = inject(Dates);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private route = inject(ActivatedRoute);
|
||||
private productsService = inject(ProductsService);
|
||||
|
||||
/** Opened from "New CC Account": only products that allow an overdraft are offered. */
|
||||
isCcRequest = false;
|
||||
/** A CC account was asked for but no current product allows an overdraft yet. */
|
||||
ccProductsMissing = false;
|
||||
|
||||
@Input() currentAccountTemplate: any;
|
||||
@Input() hideButtons = false;
|
||||
@@ -216,8 +225,13 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
this.isCcRequest = this.route.snapshot.queryParamMap.get('cc') === '1';
|
||||
|
||||
if (this.currentAccountTemplate) {
|
||||
this.productData = this.currentAccountTemplate.productOptions;
|
||||
if (this.isCcRequest && !this.currentAccountTemplate.accountNo) {
|
||||
this.limitProductsToCc();
|
||||
}
|
||||
const existingProductId =
|
||||
this.currentAccountTemplate.currentAccountProductId ?? this.currentAccountTemplate.productId;
|
||||
// Edit mode: account response always has accountNo; pure template has previewAccountNo only
|
||||
@@ -255,6 +269,24 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Cash Credit account takes its overdraft and limit from the product it is opened on, so a CC account is opened on a
|
||||
* product that allows an overdraft. The template lists every current product without saying which allow one, so the
|
||||
* product list is read to tell them apart.
|
||||
*/
|
||||
private limitProductsToCc(): void {
|
||||
this.productsService
|
||||
.getCurrentProducts()
|
||||
.pipe(catchError(() => of([] as any[])))
|
||||
.subscribe((res: any) => {
|
||||
const products: any[] = Array.isArray(res) ? res : (res?.pageItems ?? []);
|
||||
const overdraftIds = new Set(products.filter((p) => p.allowOverdraft).map((p) => p.id));
|
||||
this.productData = (this.productData || []).filter((p: any) => overdraftIds.has(p.id));
|
||||
this.ccProductsMissing = this.productData.length === 0;
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
}
|
||||
|
||||
createCurrentAccountDetailsForm() {
|
||||
this.currentAccountDetailsForm = this.formBuilder.group({
|
||||
productId: [
|
||||
|
||||
+3
-77
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-78
@@ -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>
|
||||
-235
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-93
@@ -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);
|
||||
}
|
||||
|
||||
-33
@@ -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 & 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 (₹)</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 }} | 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
-144
@@ -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 & 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>
|
||||
-167
@@ -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;
|
||||
}
|
||||
}
|
||||
-109
@@ -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';
|
||||
|
||||
+7
-669
@@ -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>
|
||||
|
||||
-653
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-173
@@ -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;
|
||||
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
+4
-57
@@ -331,14 +331,12 @@
|
||||
<span class="fdrc-maturity-row__value">{{ matchResult.appliedRate | number: '1.2-2' }}% p.a.</span>
|
||||
</div>
|
||||
<div class="fdrc-maturity-row fdrc-maturity-row--interest">
|
||||
<span class="fdrc-maturity-row__label">{{
|
||||
isSpecial ? 'Total Interest (paid out monthly)' : 'Interest Earned'
|
||||
}}</span>
|
||||
<span class="fdrc-maturity-row__label">Interest Earned</span>
|
||||
<span class="fdrc-maturity-row__value">₹ {{ matchResult.interestEarned | number: '1.2-2' }}</span>
|
||||
</div>
|
||||
@if (isSpecial && matchResult.estimatedMonthlyPayout) {
|
||||
<div class="fdrc-maturity-row">
|
||||
<span class="fdrc-maturity-row__label">Monthly Payout (approx., by days in the month)</span>
|
||||
<span class="fdrc-maturity-row__label">Monthly Payout</span>
|
||||
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--highlight">
|
||||
₹ {{ matchResult.estimatedMonthlyPayout | number: '1.2-2' }} / month
|
||||
</span>
|
||||
@@ -353,64 +351,13 @@
|
||||
}
|
||||
</span>
|
||||
<span class="fdrc-maturity-row__value fdrc-maturity-row__value--big">
|
||||
₹ {{ matchResult.maturityAmount | number: '1.2-2' }}
|
||||
₹ {{ (isSpecial ? principalAmount : matchResult.maturityAmount) | number: '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (isSpecial && specialPreview) {
|
||||
<div class="fdrc-maturity-row">
|
||||
<span class="fdrc-maturity-row__label">Maturity Date</span>
|
||||
<span class="fdrc-maturity-row__value">{{
|
||||
specialPreview.maturityDate | date: 'dd MMM yyyy'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="fdrc-maturity-row">
|
||||
<span class="fdrc-maturity-row__label">Monthly payouts</span>
|
||||
<span class="fdrc-maturity-row__value">{{ specialPreview.numberOfPayouts }}</span>
|
||||
</div>
|
||||
<div class="fdrc-maturity-row">
|
||||
<span class="fdrc-maturity-row__label">Principal + all payouts</span>
|
||||
<span class="fdrc-maturity-row__value"
|
||||
>₹ {{ specialPreview.totalValueAtMaturity | number: '1.2-2' }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isSpecial && specialPreview) {
|
||||
<div class="fdrc-schedule">
|
||||
<div class="fdrc-schedule__head">
|
||||
<span><i class="fas fa-calendar-alt"></i> Monthly Interest Payout Schedule</span>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
class="fdrc-schedule__toggle"
|
||||
(click)="openSpecialSchedule()"
|
||||
>
|
||||
View payout schedule ({{ specialPreview.numberOfPayouts }})
|
||||
</button>
|
||||
</div>
|
||||
<div class="fdrc-schedule__note">
|
||||
From {{ specialPreview.startDate | date: 'dd MMM yyyy' }} (the day the account is opened) to
|
||||
{{ specialPreview.maturityDate | date: 'dd MMM yyyy' }}, worked out by the account engine from the
|
||||
product.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (specialPreviewLoading) {
|
||||
<div class="fdrc-payout-hint">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
Working out the maturity from the product…
|
||||
</div>
|
||||
}
|
||||
@if (specialPreviewError) {
|
||||
<div class="fdrc-payout-hint fdrc-payout-hint--error">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{{ specialPreviewError }}
|
||||
</div>
|
||||
}
|
||||
@if (!matchResult.maturityAmount && !specialPreviewLoading && !specialPreviewError) {
|
||||
@if (!matchResult.maturityAmount) {
|
||||
<div class="fdrc-payout-hint">
|
||||
<i class="fas fa-lightbulb"></i>
|
||||
Enter a deposit amount above to see the maturity calculation.
|
||||
|
||||
-69
@@ -1229,72 +1229,3 @@ $red-light: #fee2e2;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* FD Special: payout schedule from the backend preview */
|
||||
.fdrc-schedule {
|
||||
margin-top: 12px;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #f0fdf4;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #166534;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__note {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
&__wrap {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 6px 14px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #f8fafc;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
font-weight: 700;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fdrc-payout-hint--error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
+78
-139
@@ -36,39 +36,12 @@ import { FormsModule } from '@angular/forms';
|
||||
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
import { FixedDepositsService } from '../../fixed-deposits.service';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { InterestScheduleDialogComponent } from '../../fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component';
|
||||
import { ClientsService } from 'app/clients/clients.service';
|
||||
import { SettingsService } from 'app/settings/settings.service';
|
||||
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
|
||||
|
||||
export type FdPeriodUnit = 'Days' | 'Months' | 'Years';
|
||||
|
||||
/**
|
||||
* yyyy-MM-dd of the calendar day as the user sees it. toISOString() gives the UTC day instead, which in India is the day
|
||||
* before for any date at local midnight - so a deposit opened on the 21st was quoted from the 20th.
|
||||
*/
|
||||
function localIsoDate(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** FD quote from POST fixeddepositaccounts/account-preview (worked out by the account engine, every FD type). */
|
||||
export interface SpecialPayoutPreview {
|
||||
fdType: string;
|
||||
depositAmount: number;
|
||||
annualInterestRate: number;
|
||||
seniorCitizen: boolean;
|
||||
startDate: string;
|
||||
maturityDate: string;
|
||||
numberOfPayouts: number;
|
||||
fullMonthPayout: number;
|
||||
totalInterest: number;
|
||||
maturityAmount: number;
|
||||
totalValueAtMaturity: number;
|
||||
periods: { period: number; fromDate: string; toDate: string; days: number; interestAmount: number }[];
|
||||
}
|
||||
|
||||
export interface SlabMatchResult {
|
||||
slab: any;
|
||||
slabIndex: number;
|
||||
@@ -114,7 +87,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
private clientsService = inject(ClientsService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private dialog = inject(MatDialog);
|
||||
|
||||
@Input() fixedDepositsAccountTemplate: any;
|
||||
@Input() fixedDepositsAccountProductTemplate: any;
|
||||
@@ -191,10 +163,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
}
|
||||
|
||||
matchResult: SlabMatchResult | null = null;
|
||||
/** FD Special only: the backend's quote and payout schedule for the tenure and amount entered. */
|
||||
specialPreview: SpecialPayoutPreview | null = null;
|
||||
specialPreviewLoading = false;
|
||||
specialPreviewError: string | null = null;
|
||||
validationWarning: string | null = null;
|
||||
|
||||
// ── FD Type labels ──────────────────────────────────────────────────────
|
||||
@@ -302,8 +270,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
calculateRate() {
|
||||
this.validationWarning = null;
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
|
||||
if (!this.tenureValue || this.tenureValue <= 0) {
|
||||
this.validationWarning = 'Please enter a valid tenure value greater than 0.';
|
||||
@@ -368,6 +334,30 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit);
|
||||
|
||||
if (this.principalAmount && this.principalAmount > 0) {
|
||||
// Years and Months convert exactly; a Days tenure is sent as days and never converted.
|
||||
let tenureInMonths: number | undefined;
|
||||
let tenureInDays: number | undefined;
|
||||
if (this.tenurePeriodUnit === 'Years') {
|
||||
tenureInMonths = this.tenureValue * 12;
|
||||
} else if (this.tenurePeriodUnit === 'Months') {
|
||||
tenureInMonths = this.tenureValue;
|
||||
} else {
|
||||
// Sent as days, whatever the count. Converting to whole months silently dropped the
|
||||
// remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the
|
||||
// screen still said "200 Days" above the figure. The backend takes days directly and
|
||||
// works the calendar out itself.
|
||||
tenureInDays = enteredDays;
|
||||
}
|
||||
|
||||
// Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual)
|
||||
const compMap: Record<number, number> = { 1: 0, 4: 1, 5: 3, 6: 6, 7: 12 };
|
||||
const compId = this.fixedDepositsAccountProductTemplate?.interestCompoundingPeriodType?.id ?? 4;
|
||||
const compMonths = compMap[compId] ?? 1;
|
||||
|
||||
// Map posting period type id → months
|
||||
const postId = this.fixedDepositsAccountProductTemplate?.interestPostingPeriodType?.id ?? 4;
|
||||
const postMonths = compMap[postId] ?? 1;
|
||||
|
||||
const emitResult = () => {
|
||||
if (frequencyId !== null) {
|
||||
this.tenureSelected.emit({
|
||||
@@ -385,7 +375,7 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
// Get submittedOnDate — Input takes priority over template data
|
||||
let submittedOnDateStr: string;
|
||||
if (this.submittedOnDate) {
|
||||
submittedOnDateStr = localIsoDate(this.submittedOnDate);
|
||||
submittedOnDateStr = this.submittedOnDate.toISOString().split('T')[0];
|
||||
} else {
|
||||
const submittedOnDateRaw =
|
||||
this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ??
|
||||
@@ -394,18 +384,63 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
const d = Array.isArray(submittedOnDateRaw)
|
||||
? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2])
|
||||
: new Date(submittedOnDateRaw);
|
||||
submittedOnDateStr = !isNaN(d.getTime()) ? localIsoDate(d) : localIsoDate(new Date());
|
||||
submittedOnDateStr = !isNaN(d.getTime())
|
||||
? d.toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0];
|
||||
} else {
|
||||
submittedOnDateStr = localIsoDate(new Date());
|
||||
submittedOnDateStr = new Date().toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
// The whole quote - rate, maturity date, maturity amount and, for FD Special, the month-by-month payout
|
||||
// schedule - comes from the backend, which builds the would-be account from the product exactly as Submit does
|
||||
// (its posting periods, compounding, days in year, rate card, senior-citizen rate) and runs the account's own
|
||||
// interest engine. Nothing is worked out here: on an error the quote is simply not shown, never estimated.
|
||||
if (frequencyId !== null) {
|
||||
this.loadAccountPreview(result, rate, submittedOnDateStr, frequencyId, emitResult);
|
||||
const daysInYearVal =
|
||||
this.fixedDepositsAccountProductTemplate?.interestCalculationDaysInYearType?.id === 360 ? 360 : 365;
|
||||
|
||||
// Always use backend API for accurate calculation (including daily compounding with 0)
|
||||
this.fixedDepositsService
|
||||
.calculateFixedDepositInterest({
|
||||
principalAmount: this.principalAmount,
|
||||
annualInterestRate: rate,
|
||||
tenureInMonths,
|
||||
tenureInDays,
|
||||
interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend
|
||||
interestPostingPeriodInMonths: postMonths,
|
||||
submittedOnDate: submittedOnDateStr,
|
||||
daysInYear: daysInYearVal
|
||||
})
|
||||
.subscribe({
|
||||
next: (apiResult: any) => {
|
||||
result.maturityAmount = apiResult.maturityAmount;
|
||||
result.interestEarned =
|
||||
apiResult.totalInterest ?? Math.round((apiResult.maturityAmount - this.principalAmount) * 100) / 100;
|
||||
this.matchResult = { ...result };
|
||||
emitResult();
|
||||
},
|
||||
error: () => {
|
||||
// Fallback if API fails: frontend formula
|
||||
const r = rate / 100;
|
||||
if (compMonths === 0) {
|
||||
// Daily compounding fallback
|
||||
result.maturityAmount =
|
||||
Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100;
|
||||
} else {
|
||||
const n = 12 / compMonths;
|
||||
const effectiveMonths = tenureInMonths ?? enteredDays / 30;
|
||||
const rPerCompounding = rate / (n * 100);
|
||||
// Whole compounding periods, then simple interest on the days left over. Flooring the
|
||||
// periods and stopping there dropped the remainder entirely: a 200-day deposit
|
||||
// compounding monthly quoted only six months of interest and lost the other twenty
|
||||
// days, which is the depositor's money.
|
||||
const wholePeriods = Math.floor(effectiveMonths / compMonths);
|
||||
const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods);
|
||||
const leftoverDays = enteredDays - wholePeriods * compMonths * 30;
|
||||
const leftoverInterest =
|
||||
leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0;
|
||||
result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100;
|
||||
}
|
||||
result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100;
|
||||
this.matchResult = { ...result };
|
||||
emitResult();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.matchResult = result;
|
||||
if (frequencyId !== null) {
|
||||
@@ -422,96 +457,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
}
|
||||
}
|
||||
|
||||
/** The backend's payout schedule in the same popup the account itself uses, in its quote (preview) mode. */
|
||||
openSpecialSchedule(): void {
|
||||
if (!this.specialPreview) return;
|
||||
this.dialog.open(InterestScheduleDialogComponent, {
|
||||
width: '760px',
|
||||
data: {
|
||||
preview: true,
|
||||
currencyCode: this.fixedDepositsAccountProductTemplate?.currency?.code ?? 'INR',
|
||||
note:
|
||||
'Worked out by the account engine from the product, starting ' +
|
||||
this.specialPreview.startDate +
|
||||
' (the day the account is opened) and ending the day before maturity on ' +
|
||||
this.specialPreview.maturityDate +
|
||||
'. The first and last periods are part-months; each pays for the days it covers.',
|
||||
schedule: this.specialPreview.periods.map((p) => ({
|
||||
fromDate: p.fromDate,
|
||||
toDate: p.toDate,
|
||||
days: p.days,
|
||||
interestAmount: Number(p.interestAmount),
|
||||
paid: false
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the backend for the quote (POST fixeddepositaccounts/account-preview) with the same terms Submit sends: the
|
||||
* product's own interest terms, and the senior-citizen rate the same way Submit overrides it. The rate, maturity
|
||||
* amount and interest shown are the backend's, so they match the account once it is created.
|
||||
*/
|
||||
private loadAccountPreview(
|
||||
result: SlabMatchResult,
|
||||
rate: number,
|
||||
submittedOnDateStr: string,
|
||||
frequencyId: number,
|
||||
emitResult: () => void
|
||||
): void {
|
||||
const pt = this.fixedDepositsAccountProductTemplate ?? {};
|
||||
const body: any = {
|
||||
clientId: pt.clientId ?? this.fixedDepositsAccountTemplate?.clientId,
|
||||
productId: pt.depositProductId ?? pt.productId ?? this.fixedDepositsAccountTemplate?.productId,
|
||||
submittedOnDate: submittedOnDateStr,
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
locale: 'en',
|
||||
depositAmount: this.principalAmount,
|
||||
depositPeriod: this.tenureValue,
|
||||
depositPeriodFrequencyId: frequencyId
|
||||
};
|
||||
if (pt.nominalAnnualInterestRate != null) body.nominalAnnualInterestRate = pt.nominalAnnualInterestRate;
|
||||
if (pt.interestCompoundingPeriodType?.id != null)
|
||||
body.interestCompoundingPeriodType = pt.interestCompoundingPeriodType.id;
|
||||
if (pt.interestPostingPeriodType?.id != null) body.interestPostingPeriodType = pt.interestPostingPeriodType.id;
|
||||
if (pt.interestCalculationType?.id != null) body.interestCalculationType = pt.interestCalculationType.id;
|
||||
if (pt.interestCalculationDaysInYearType?.id != null)
|
||||
body.interestCalculationDaysInYearType = pt.interestCalculationDaysInYearType.id;
|
||||
if (this.isSeniorCitizen && rate > 0) {
|
||||
body.isSeniorCitizen = true;
|
||||
body.seniorCitizenInterestRate = rate;
|
||||
body.nominalAnnualInterestRate = rate;
|
||||
}
|
||||
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
this.specialPreviewLoading = true;
|
||||
this.fixedDepositsService.accountPreview(body).subscribe({
|
||||
next: (preview: SpecialPayoutPreview) => {
|
||||
this.specialPreviewLoading = false;
|
||||
this.specialPreview = preview;
|
||||
result.appliedRate = Number(preview.annualInterestRate);
|
||||
result.interestEarned = Number(preview.totalInterest);
|
||||
result.estimatedMonthlyPayout = preview.fullMonthPayout != null ? Number(preview.fullMonthPayout) : undefined;
|
||||
result.maturityAmount = Number(preview.maturityAmount);
|
||||
this.matchResult = { ...result };
|
||||
emitResult();
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: (e: any) => {
|
||||
this.specialPreviewLoading = false;
|
||||
this.specialPreviewError =
|
||||
e?.error?.errors?.[0]?.defaultUserMessage ||
|
||||
e?.error?.defaultUserMessage ||
|
||||
'Could not work out the maturity. Check the deposit details and try again.';
|
||||
result.maturityAmount = undefined;
|
||||
result.interestEarned = undefined;
|
||||
this.matchResult = { ...result };
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveFrequencyId(unit: FdPeriodUnit): number | null {
|
||||
const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? [];
|
||||
const unitLower = unit.toLowerCase();
|
||||
@@ -526,16 +471,12 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
this.tenureValue = null;
|
||||
this.principalAmount = null;
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
this.validationWarning = null;
|
||||
this.tenurePeriodUnit = this.allowedPeriodUnits[0];
|
||||
}
|
||||
|
||||
onPeriodUnitChange() {
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
this.validationWarning = null;
|
||||
}
|
||||
|
||||
@@ -583,8 +524,6 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
|
||||
this.calculateRate();
|
||||
} else {
|
||||
this.matchResult = null;
|
||||
this.specialPreview = null;
|
||||
this.specialPreviewError = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-7
@@ -326,7 +326,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Inactive</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToInactive ?? null) !== null) {
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToInactive != null) {
|
||||
After {{ fixedDepositsAccountProductTemplate.daysToInactive }} days of no transaction
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -340,7 +340,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Dormant</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToDormancy ?? null) !== null) {
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToDormancy != null) {
|
||||
{{ fixedDepositsAccountProductTemplate.daysToDormancy }} days after inactive
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -354,7 +354,7 @@
|
||||
<div class="preview-dorm-step__body">
|
||||
<span class="preview-dorm-step__label">Escheat</span>
|
||||
<span class="preview-dorm-step__days">
|
||||
@if ((fixedDepositsAccountProductTemplate?.daysToEscheat ?? null) !== null) {
|
||||
@if (fixedDepositsAccountProductTemplate?.daysToEscheat != null) {
|
||||
{{ fixedDepositsAccountProductTemplate.daysToEscheat }} days after dormant
|
||||
} @else {
|
||||
Not configured
|
||||
@@ -458,15 +458,13 @@
|
||||
<span class="preview-value">{{ maturitySummary.tenureLabel }}</span>
|
||||
</div>
|
||||
<div class="preview-field">
|
||||
<span class="preview-label">{{
|
||||
isSpecial ? 'Total Interest (paid out monthly)' : 'Estimated Interest Earned'
|
||||
}}</span>
|
||||
<span class="preview-label">Estimated Interest Earned</span>
|
||||
<span class="preview-value" style="color: var(--theme-success, #28a745); font-weight: 500">
|
||||
+ {{ maturitySummary.totalInterest | formatNumber }} {{ currencyName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="preview-field" style="grid-column: span 2">
|
||||
<span class="preview-label">{{ isSpecial ? 'Principal at Maturity' : 'Projected Maturity Amount' }}</span>
|
||||
<span class="preview-label">Projected Maturity Amount</span>
|
||||
<span class="preview-value preview-mono" style="font-weight: 600; font-size: 1.25rem">
|
||||
{{ maturitySummary.maturityAmount | formatNumber }} {{ currencyName }}
|
||||
</span>
|
||||
|
||||
-5
@@ -145,11 +145,6 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
|
||||
SPECIAL: 'FD Special (Monthly Interest Payout)'
|
||||
};
|
||||
|
||||
/** FD Special pays its interest out monthly; the principal alone comes back at maturity. */
|
||||
get isSpecial(): boolean {
|
||||
return this.fixedDepositsAccountProductTemplate?.fdType === 'SPECIAL';
|
||||
}
|
||||
|
||||
getFdTypeLabel(fdType: string): string {
|
||||
return this.fdTypeLabels[fdType] || fdType;
|
||||
}
|
||||
|
||||
+4
-26
@@ -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>
|
||||
|
||||
+2
-13
@@ -26,9 +26,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
* a 24+ month term makes for a long table that would otherwise push every other card on the tab down
|
||||
* the page. Each row also shows whether that period's interest has actually posted yet, not just what
|
||||
* the engine projects it to be.
|
||||
*
|
||||
* Also opened from the account-opening screen with `preview: true`: the backend's quote for an FD Special
|
||||
* not opened yet, so there is no paid / pending status, and each period shows its number of days.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-interest-schedule-dialog',
|
||||
@@ -49,22 +46,14 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
export class InterestScheduleDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
|
||||
data: {
|
||||
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }>;
|
||||
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>;
|
||||
currencyCode: string;
|
||||
/** Account-opening quote: no paid / pending status. */
|
||||
preview?: boolean;
|
||||
/** Shown above the table instead of the account note. */
|
||||
note?: string;
|
||||
} = inject(MAT_DIALOG_DATA);
|
||||
|
||||
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean; days?: number }> {
|
||||
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> {
|
||||
return this.data.schedule ?? [];
|
||||
}
|
||||
|
||||
get isPreview(): boolean {
|
||||
return !!this.data.preview;
|
||||
}
|
||||
|
||||
get currencyCode(): string {
|
||||
return this.data.currencyCode;
|
||||
}
|
||||
|
||||
@@ -154,16 +154,6 @@ export class FixedDepositsService {
|
||||
return this.http.get(`/fixeddepositaccounts/calculate-fd-interest`, { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* FD quote while opening the account, for every FD type: the same body as create (clientId, productId,
|
||||
* submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, interest terms, senior citizen ...). The
|
||||
* backend builds the would-be account from the product, without saving it, and returns the applied rate, maturity
|
||||
* date and amount Submit would store, and the interest periods (FD Special: the monthly payout schedule).
|
||||
*/
|
||||
accountPreview(body: any): Observable<any> {
|
||||
return this.http.post(`/fixeddepositaccounts/account-preview`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* FD Dashboard's "Renew" action on a matured FD whose product has a renewal grace period
|
||||
* configured: backdated to the original maturity date within the grace window, or from today
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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
|
||||
|
||||
+53
-241
@@ -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>
|
||||
|
||||
+54
-473
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-153
@@ -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);
|
||||
|
||||
+3
-4
@@ -110,8 +110,8 @@ export class CreateRecurringDepositsAccountComponent {
|
||||
*/
|
||||
setTemplate($event: any) {
|
||||
this.recurringDepositsAccountProductTemplate = $event;
|
||||
this.productsService.getRdBaseInterestRates().subscribe({
|
||||
next: (rates: any) => (this.cbsBaseRates = Array.isArray(rates) ? rates : rates ? [rates] : []),
|
||||
this.productsService.getFdBaseInterestRates().subscribe({
|
||||
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
|
||||
error: () => (this.cbsBaseRates = [])
|
||||
});
|
||||
}
|
||||
@@ -198,8 +198,7 @@ export class CreateRecurringDepositsAccountComponent {
|
||||
if (this.recurringDepositsAccountProductTemplate?.introducerRequired && !detailsStep?.introducerId) {
|
||||
this.alertService.alert({
|
||||
type: 'Error',
|
||||
message:
|
||||
'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
|
||||
message: 'This product requires an introducer. Please choose an existing customer as introducer before submitting.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
+2
-2
@@ -144,8 +144,8 @@ export class EditRecurringDepositAccountComponent {
|
||||
|
||||
setTemplate($event: any) {
|
||||
this.recurringDepositsAccountProductTemplate = $event;
|
||||
this.productsService.getRdBaseInterestRates().subscribe({
|
||||
next: (rates: any) => (this.cbsBaseRates = Array.isArray(rates) ? rates : rates ? [rates] : []),
|
||||
this.productsService.getFdBaseInterestRates().subscribe({
|
||||
next: (rates: any[]) => (this.cbsBaseRates = rates || []),
|
||||
error: () => (this.cbsBaseRates = [])
|
||||
});
|
||||
}
|
||||
|
||||
+2
-6
@@ -127,8 +127,7 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
}>();
|
||||
|
||||
get activeBase(): any {
|
||||
// The recurring deposit's own CBS base chart; its active version carries the senior citizen rate of each slab.
|
||||
return (this.cbsBaseRates || []).find((r) => r.isActive) ?? (this.cbsBaseRates || [])[0];
|
||||
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,10 +173,7 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
|
||||
return baseSlabs;
|
||||
}
|
||||
// Products made before RD charts moved to months still hold the base chart's own year slabs
|
||||
const candidates = [
|
||||
...baseSlabs,
|
||||
...this.normalizeCbsSlabs(cbsSlabs)
|
||||
];
|
||||
const candidates = [...baseSlabs, ...this.normalizeCbsSlabs(cbsSlabs)];
|
||||
return productSlabs.map((slab: any) => {
|
||||
if (slab.seniorCitizenRate != null) {
|
||||
return slab;
|
||||
|
||||
-28
@@ -50,34 +50,6 @@
|
||||
<span class="nx-row__label">{{ 'labels.inputs.Maturity Date' | translate }}</span>
|
||||
<span class="nx-row__value">{{ recurringDepositsAccountData.maturityDate | dateFormat }}</span>
|
||||
</div>
|
||||
@if (installmentPosition) {
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">Next Installment Due</span>
|
||||
<span class="nx-row__value">
|
||||
@if (installmentPosition.nextDueDate) {
|
||||
{{ installmentPosition.nextDueDate | dateFormat }} ·
|
||||
{{ installmentPosition.installmentAmount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||
} @else {
|
||||
<span class="nx-muted">All installments paid</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">Installments Paid</span>
|
||||
<span class="nx-row__value"
|
||||
>{{ installmentPosition.paidInstallments }} of {{ installmentPosition.totalInstallments }}</span
|
||||
>
|
||||
</div>
|
||||
@if (installmentPosition.overdueInstallments > 0) {
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">Overdue</span>
|
||||
<span class="nx-row__value" style="color: var(--mat-sys-error, #d32f2f)">
|
||||
{{ installmentPosition.overdueInstallments }} installment(s) ·
|
||||
{{ installmentPosition.overdueAmount | currency: currency?.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (
|
||||
recurringDepositsAccountData.timeline.closedOnDate || recurringDepositsAccountData.summary.totalWitddrawals
|
||||
) {
|
||||
|
||||
-31
@@ -46,21 +46,11 @@ export class GeneralTabComponent {
|
||||
totalLienAmount = 0;
|
||||
/** Loan limit today (Lien Marked products only): % that applies, what it comes to, and what is left. */
|
||||
lienLimit: { percentage: number; maxLoanAmount: number; availableForLoan: number } | null = null;
|
||||
/** When the next installment is expected and how much, plus anything already overdue; null once fully paid. */
|
||||
installmentPosition: {
|
||||
nextDueDate: string | null;
|
||||
installmentAmount: number;
|
||||
paidInstallments: number;
|
||||
totalInstallments: number;
|
||||
overdueInstallments: number;
|
||||
overdueAmount: number;
|
||||
} | null = null;
|
||||
|
||||
constructor() {
|
||||
this.route.parent.data.subscribe((data: { recurringDepositsAccountData: any }) => {
|
||||
this.recurringDepositsAccountData = data.recurringDepositsAccountData;
|
||||
this.loadLiens(this.recurringDepositsAccountData?.id);
|
||||
this.loadInstallmentPosition(this.recurringDepositsAccountData?.id);
|
||||
this.currency = this.recurringDepositsAccountData.currency;
|
||||
this.isprematureAllowed = data.recurringDepositsAccountData.maturityDate != null;
|
||||
if (this.router.url.includes('clients')) {
|
||||
@@ -73,27 +63,6 @@ export class GeneralTabComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private loadInstallmentPosition(accountId: any) {
|
||||
this.installmentPosition = null;
|
||||
if (!accountId) {
|
||||
return;
|
||||
}
|
||||
this.recurringDepositsService.getRecurringDepositInstallmentPosition(accountId).subscribe((response: any) => {
|
||||
const row = (response?.accounts ?? []).find((a: any) => Number(a.id) === Number(accountId));
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
this.installmentPosition = {
|
||||
nextDueDate: row.nextDueDate || null,
|
||||
installmentAmount: Number(row.installmentAmount) || 0,
|
||||
paidInstallments: Number(row.paidInstallments) || 0,
|
||||
totalInstallments: Number(row.totalInstallments) || 0,
|
||||
overdueInstallments: Number(row.overdueInstallments) || 0,
|
||||
overdueAmount: Number(row.overdueAmount) || 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private loadLiens(accountId: any) {
|
||||
this.liens = [];
|
||||
this.totalLienAmount = 0;
|
||||
|
||||
@@ -39,16 +39,6 @@ export class RecurringDepositsService {
|
||||
return this.http.get(`/recurringdepositaccounts/${recurringDepositAccountId}/liens`);
|
||||
}
|
||||
|
||||
/**
|
||||
* This deposit's installment position as of the business date (next due date, overdue count/amount), from the
|
||||
* RD Dashboard endpoint - the same paid/overdue rule the auto-debit and penalty jobs use. Older backends ignore
|
||||
* `accountId` and return every RD, so callers pick the row by id.
|
||||
*/
|
||||
getRecurringDepositInstallmentPosition(recurringDepositAccountId: any): Observable<any> {
|
||||
const httpParams = new HttpParams().set('accountId', recurringDepositAccountId);
|
||||
return this.http.get('/recurringdepositaccounts/dashboard', { params: httpParams });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param clientId Client Id
|
||||
* @param clientName Client Name
|
||||
|
||||
+6
-12
@@ -719,8 +719,7 @@
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
input[type='number'] {
|
||||
[type='number'] {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
@@ -760,8 +759,7 @@
|
||||
}
|
||||
|
||||
/* :where() keeps these defaults at zero specificity, so the table's own cell inputs always win. */
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
:where(input[type='text'], input[type='number'], input[type='date'], select, textarea) {
|
||||
:where([type='text'], [type='number'], [type='date'], select, textarea) {
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
padding: 6px 8px;
|
||||
@@ -773,13 +771,11 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
:where(input[type='text'], select) {
|
||||
:where([type='text'], select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
:where(input[type='checkbox']) {
|
||||
:where([type='checkbox']) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@@ -869,8 +865,7 @@
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
input[type='number'] {
|
||||
[type='number'] {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
@@ -1180,8 +1175,7 @@
|
||||
gap: 8px;
|
||||
max-width: 720px;
|
||||
|
||||
/* stylelint-disable-next-line selector-no-qualifying-type */
|
||||
input[type='text'] {
|
||||
[type='text'] {
|
||||
flex: 1;
|
||||
padding: 9px 11px;
|
||||
font-size: 13px;
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface ReserveTransferData {
|
||||
export class ReserveTransferDialogComponent {
|
||||
private dialogRef = inject(MatDialogRef<ReserveTransferDialogComponent>);
|
||||
private rbi = inject(RbiCircularsService);
|
||||
readonly data: ReserveTransferData = inject(MAT_DIALOG_DATA);
|
||||
|
||||
/** The account money leaves. Fixed — the dialog is opened from its row. */
|
||||
readonly from: TransferAccount;
|
||||
@@ -75,8 +76,6 @@ export class ReserveTransferDialogComponent {
|
||||
submitting = false;
|
||||
error = '';
|
||||
|
||||
readonly data = inject<ReserveTransferData>(MAT_DIALOG_DATA);
|
||||
|
||||
constructor() {
|
||||
this.from = this.data.from;
|
||||
this.destinations = (this.data.accounts ?? []).filter((a) => a.glAccountId !== this.data.from.glAccountId);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { Route } from '../core/route/route.service';
|
||||
|
||||
const routes: Routes = [
|
||||
Route.withShell([
|
||||
{
|
||||
path: 'ai/loan-eligibility',
|
||||
loadComponent: () => import('./loan-eligibility.component').then((m) => m.LoanEligibilityComponent),
|
||||
data: { title: 'Loan Eligibility', breadcrumb: 'Loan Eligibility' }
|
||||
}
|
||||
])
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class LoanEligibilityRoutingModule {}
|
||||
@@ -0,0 +1,482 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="pg">
|
||||
@if (!loading) {
|
||||
<header class="pg-head">
|
||||
<div>
|
||||
<div class="pg-eyebrow">AI / INTELLIGENCE · LENDING & RISK</div>
|
||||
<h1 class="pg-title">Loan Eligibility</h1>
|
||||
<p class="pg-sub">
|
||||
How our loans are running (RBI asset classification) and which loan products the good borrowers are eligible
|
||||
for, using the eligibility rules set on each loan product.
|
||||
@if (summary) {
|
||||
As on {{ summary.asOnDate }}.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
@if (error) {
|
||||
<p class="pg-sub le-error">{{ error }}</p>
|
||||
}
|
||||
@if (loading) {
|
||||
<div class="le-loader">
|
||||
<div class="le-spinner"></div>
|
||||
<div class="le-loader-title">Loading loan book…</div>
|
||||
<ul class="le-loader-steps">
|
||||
@for (st of loadingSteps; track $index) {
|
||||
<li [class.le-done]="$index < loadingStep" [class.le-now]="$index === loadingStep">
|
||||
{{ $index < loadingStep ? '✓' : $index === loadingStep ? '…' : '' }} {{ st }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="le-progress"><span [style.width.%]="((loadingStep + 1) / loadingSteps.length) * 100"></span></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (summary; as s) {
|
||||
<div class="le-kpis">
|
||||
<div class="le-kpi">
|
||||
<span class="le-kpi-l">Active loans</span>
|
||||
<span class="le-kpi-v">{{ s.activeLoans }}</span>
|
||||
<span class="le-kpi-s">₹{{ s.activeOutstanding | number: '1.0-0' }} outstanding</span>
|
||||
</div>
|
||||
<div class="le-kpi le-kpi--good">
|
||||
<span class="le-kpi-l">Running well (Standard + SMA)</span>
|
||||
<span class="le-kpi-v">{{ s.performingLoans }}</span>
|
||||
<span class="le-kpi-s">₹{{ s.performingOutstanding | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="le-kpi le-kpi--bad">
|
||||
<span class="le-kpi-l">Gone bad (NPA)</span>
|
||||
<span class="le-kpi-v">{{ s.npaLoans }}</span>
|
||||
<span class="le-kpi-s">₹{{ s.npaOutstanding | number: '1.0-0' }} · Gross NPA {{ s.grossNpaPct }}%</span>
|
||||
</div>
|
||||
<div class="le-kpi">
|
||||
<span class="le-kpi-l">Written off / Closed</span>
|
||||
<span class="le-kpi-v">{{ s.writtenOffLoans }} / {{ s.closedLoans }}</span>
|
||||
<span class="le-kpi-s">₹{{ s.writtenOffAmount | number: '1.0-0' }} written off</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cctr-tabs le-tabs le-views">
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="view === 'BOOK'"
|
||||
(click)="view = 'BOOK'"
|
||||
>
|
||||
Loan book (RBI classes & products)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="view === 'BORROWERS'"
|
||||
(click)="view = 'BORROWERS'"
|
||||
>
|
||||
Borrowers & eligibility (Good {{ s.goodBorrowers }} · Watch {{ s.watchBorrowers }} · Bad
|
||||
{{ s.badBorrowers }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (view === 'BOOK') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Loans by RBI asset classification (days past due)</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Class</th>
|
||||
<th>Days past due</th>
|
||||
<th class="num">Loans</th>
|
||||
<th class="num">Outstanding</th>
|
||||
<th class="num">Overdue</th>
|
||||
<th>Share of book</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (st of performingStages; track st.stage) {
|
||||
<tr>
|
||||
<td>
|
||||
<span
|
||||
class="le-dot"
|
||||
[class.le-dot--good]="st.stage === 'STANDARD'"
|
||||
[class.le-dot--warn]="st.stage !== 'STANDARD'"
|
||||
></span>
|
||||
{{ st.label }}
|
||||
</td>
|
||||
<td>{{ st.dpdRange }}</td>
|
||||
<td class="num">{{ st.loans }}</td>
|
||||
<td class="num">₹{{ st.outstanding | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ st.overdue | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="le-bar"><span [style.width.%]="share(st)"></span></span> {{ share(st) }}%
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@for (st of npaStages; track st.stage) {
|
||||
<tr>
|
||||
<td><span class="le-dot le-dot--bad"></span> {{ st.label }} (NPA)</td>
|
||||
<td>{{ st.dpdRange }}</td>
|
||||
<td class="num">{{ st.loans }}</td>
|
||||
<td class="num">₹{{ st.outstanding | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ st.overdue | number: '1.2-2' }}</td>
|
||||
<td>
|
||||
<span class="le-bar le-bar--bad"><span [style.width.%]="share(st)"></span></span> {{ share(st) }}%
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@for (st of closedStages; track st.stage) {
|
||||
<tr class="le-muted">
|
||||
<td>{{ st.label }}</td>
|
||||
<td>{{ st.dpdRange }}</td>
|
||||
<td class="num">{{ st.loans }}</td>
|
||||
<td class="num">–</td>
|
||||
<td class="num">–</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Active loans by product</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th class="num">Active</th>
|
||||
<th class="num">Running well</th>
|
||||
<th class="num">NPA</th>
|
||||
<th class="num">Outstanding</th>
|
||||
<th class="num">NPA outstanding</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (p of s.products; track p.productId) {
|
||||
<tr>
|
||||
<td>{{ p.productName }}</td>
|
||||
<td class="num">{{ p.activeLoans }}</td>
|
||||
<td class="num">{{ p.performingLoans }}</td>
|
||||
<td class="num">{{ p.npaLoans }}</td>
|
||||
<td class="num">₹{{ p.outstanding | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ p.npaOutstanding | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No active loans.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">Share linking to borrowing (5% unsecured · 2.5% secured)</div>
|
||||
<div class="le-facts">
|
||||
<span>Shares required on running loans: ₹{{ s.shareRequired | number: '1.0-0' }}</span>
|
||||
<span>Shares held by these members: ₹{{ s.shareHeld | number: '1.0-0' }}</span>
|
||||
<span [class.le-late]="s.shareShortMembers > 0"
|
||||
>Members short: {{ s.shareShortMembers }} (₹{{ s.shareShortfall | number: '1.0-0' }} to collect)</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (view === 'BORROWERS') {
|
||||
<div class="pg-card">
|
||||
<div class="pos-section-label">
|
||||
Borrowers — Good {{ s.goodBorrowers }} · Watch {{ s.watchBorrowers }} · Bad {{ s.badBorrowers }}
|
||||
</div>
|
||||
<div class="cctr-tabs le-tabs">
|
||||
@for (c of categories; track c.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[class.pg-btn--active]="category === c.value"
|
||||
(click)="setCategory(c.value)"
|
||||
>
|
||||
{{ c.label }}
|
||||
</button>
|
||||
}
|
||||
<input
|
||||
type="text"
|
||||
class="le-search"
|
||||
placeholder="Search name / account no"
|
||||
[(ngModel)]="search"
|
||||
(keyup.enter)="loadBorrowers()"
|
||||
/>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="loadBorrowers()">Search</button>
|
||||
</div>
|
||||
<p class="pg-sub le-help">
|
||||
Good: every running loan is regular (0 days past due) and nothing was ever written off. Watch: a loan is
|
||||
overdue up to 90 days (SMA). Bad: a loan is NPA or written off. Eligibility is worked out for good borrowers
|
||||
only; a fresh loan also needs a clean EMI record (at most 2 EMIs ever paid late).
|
||||
</p>
|
||||
<table class="pg-table le-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Borrower</th>
|
||||
<th>Category</th>
|
||||
<th class="num">Running / Closed</th>
|
||||
<th>EMI record</th>
|
||||
<th class="num">Outstanding</th>
|
||||
<th class="num">Max days overdue</th>
|
||||
<th>Worst class</th>
|
||||
<th>More loan?</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (borrowersLoading) {
|
||||
<tr>
|
||||
<td colspan="9" class="pg-empty">
|
||||
<span class="le-spinner le-spinner--sm"></span> Fetching borrowers…
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@for (b of borrowersLoading ? [] : pagedBorrowers; track b.clientId) {
|
||||
<tr [class.le-selected]="selected?.clientId === b.clientId">
|
||||
<td>
|
||||
{{ b.clientName }} <small>({{ b.clientAccountNo }})</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="le-chip" [ngClass]="'le-chip--' + b.category.toLowerCase()">{{
|
||||
categoryLabel(b.category)
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="num">{{ b.activeLoans }} / {{ b.closedLoans }}</td>
|
||||
<td>
|
||||
{{ b.emisPaid - b.emisLate }} / {{ b.emisPaid }} on time
|
||||
@if (b.emisLate) {
|
||||
<small class="le-late">({{ b.emisLate }} late)</small>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ b.outstanding | number: '1.2-2' }}</td>
|
||||
<td class="num">{{ b.maxDpd }}</td>
|
||||
<td>{{ b.worstStage || '–' }}</td>
|
||||
<td>
|
||||
@if (b.eligibleProducts > 0) {
|
||||
<span class="le-chip le-chip--good">Yes · {{ b.eligibleProducts }} products</span>
|
||||
} @else {
|
||||
<span class="le-chip le-chip--bad">No</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="openingId !== null" (click)="open(b)">
|
||||
{{ openingId === b.clientId ? 'Loading…' : 'View' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="9" class="pg-empty">No borrowers in this category.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@if (borrowers.length > pageSize) {
|
||||
<div class="le-pager">
|
||||
<span>Showing {{ page * pageSize + 1 }}–{{ pageEnd }} of {{ borrowers.length }}</span>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [disabled]="page === 0" (click)="page = page - 1">
|
||||
‹ Prev
|
||||
</button>
|
||||
@for (p of pages; track p) {
|
||||
<button type="button" class="pg-btn pg-btn--ghost" [class.pg-btn--active]="p === page" (click)="page = p">
|
||||
{{ p + 1 }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn pg-btn--ghost"
|
||||
[disabled]="page >= pages.length - 1"
|
||||
(click)="page = page + 1"
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selected; as b) {
|
||||
<div class="le-overlay" (click)="close()">
|
||||
<div class="le-modal" role="dialog" aria-modal="true" (click)="$event.stopPropagation()">
|
||||
<div class="le-modal-head">
|
||||
<div class="pos-section-label">
|
||||
{{ b.clientName }} ({{ b.clientAccountNo }}) — {{ categoryLabel(b.category) }} borrower
|
||||
</div>
|
||||
<button type="button" class="pg-btn pg-btn--ghost" (click)="close()">Close</button>
|
||||
</div>
|
||||
<div class="le-facts">
|
||||
<span>Age: {{ b.age ?? 'not recorded' }}</span>
|
||||
<span
|
||||
>Monthly income: {{ b.monthlyIncome ? '₹' + (b.monthlyIncome | number: '1.0-0') : 'not recorded' }}</span
|
||||
>
|
||||
<span>Current EMIs: ₹{{ b.currentEmi | number: '1.0-0' }}</span>
|
||||
<span>EMI record: {{ b.emisPaid - b.emisLate }} of {{ b.emisPaid }} paid on time</span>
|
||||
<span>Deposits (savings / FD / RD): ₹{{ b.depositBalance | number: '1.0-0' }}</span>
|
||||
<span [class.le-late]="b.shareHeld < b.shareRequired"
|
||||
>Shares held: ₹{{ b.shareHeld | number: '1.0-0' }} (needed ₹{{ b.shareRequired | number: '1.0-0' }} incl.
|
||||
membership)</span
|
||||
>
|
||||
</div>
|
||||
|
||||
@for (a of b.accounts ?? []; track a.accountNo) {
|
||||
<div class="pos-section-label">
|
||||
{{ a.type }} account {{ a.accountNo }} — last 6 months (money in, expenses, balance)
|
||||
</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Month</th>
|
||||
<th class="num">Credits (in)</th>
|
||||
<th class="num">Debits (expenses + EMIs)</th>
|
||||
<th class="num">Left over</th>
|
||||
<th class="num">Average balance</th>
|
||||
<th class="num">Cheque / ECS returns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (m of a.months; track m.month) {
|
||||
<tr>
|
||||
<td>{{ m.month }}</td>
|
||||
<td class="num">₹{{ m.credits | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ m.debits | number: '1.0-0' }}</td>
|
||||
<td class="num" [class.le-late]="m.credits < m.debits">
|
||||
₹{{ m.credits - m.debits | number: '1.0-0' }}
|
||||
</td>
|
||||
<td class="num">₹{{ m.avgBalance | number: '1.0-0' }}</td>
|
||||
<td class="num" [class.le-late]="m.returns > 0">{{ m.returns }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr class="le-total">
|
||||
<td>Monthly average</td>
|
||||
<td class="num">₹{{ b.avgCredits | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ b.avgDebits | number: '1.0-0' }}</td>
|
||||
<td class="num" [class.le-late]="b.avgCredits < b.avgDebits">
|
||||
₹{{ b.avgCredits - b.avgDebits | number: '1.0-0' }}
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (b.avgCredits > 0) {
|
||||
Spends {{ (b.avgDebits / b.avgCredits) * 100 | number: '1.0-0' }}% of income
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ b.returns }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<div class="pos-section-label">Loans</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Loan</th>
|
||||
<th>Product</th>
|
||||
<th>Status</th>
|
||||
<th>Class</th>
|
||||
<th class="num">Days overdue</th>
|
||||
<th class="num">EMIs on time</th>
|
||||
<th class="num">Disbursed</th>
|
||||
<th class="num">Outstanding</th>
|
||||
<th class="num">Overdue</th>
|
||||
<th class="num">Shares needed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (l of b.loans ?? []; track l.loanId) {
|
||||
<tr>
|
||||
<td>{{ l.accountNo }}</td>
|
||||
<td>{{ l.productName }}</td>
|
||||
<td>{{ l.status }}</td>
|
||||
<td>{{ l.stage }}</td>
|
||||
<td class="num">{{ l.dpd }}</td>
|
||||
<td class="num">{{ l.emisPaid - l.emisLate }} / {{ l.emisPaid }}</td>
|
||||
<td class="num">₹{{ l.disbursed | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ l.outstanding | number: '1.2-2' }}</td>
|
||||
<td class="num">₹{{ l.overdue | number: '1.2-2' }}</td>
|
||||
<td class="num">
|
||||
@if (l.shareRequired) {
|
||||
₹{{ l.shareRequired | number: '1.0-0' }} <small>({{ l.sharePct }}%)</small>
|
||||
} @else {
|
||||
–
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (b.category === 'GOOD') {
|
||||
<div class="pos-section-label">Eligible loan products</div>
|
||||
<table class="pg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th>Eligible</th>
|
||||
<th class="num">Up to</th>
|
||||
<th class="num">Shares to take</th>
|
||||
<th class="num">In hand</th>
|
||||
<th>Why</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (e of b.eligibility ?? []; track e.productId) {
|
||||
<tr>
|
||||
<td>{{ e.productName }}</td>
|
||||
<td>
|
||||
<span class="le-chip" [ngClass]="e.eligible ? 'le-chip--good' : 'le-chip--bad'">{{
|
||||
e.eligible ? 'Yes' : 'No'
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="num">{{ e.eligibleAmount ? '₹' + (e.eligibleAmount | number: '1.0-0') : '–' }}</td>
|
||||
<td class="num">
|
||||
@if (e.shareToBuy) {
|
||||
₹{{ e.shareToBuy | number: '1.0-0' }} <small>({{ e.sharePct }}%)</small>
|
||||
} @else {
|
||||
–
|
||||
}
|
||||
</td>
|
||||
<td class="num">{{ e.netInHand ? '₹' + (e.netInHand | number: '1.0-0') : '–' }}</td>
|
||||
<td class="le-why">
|
||||
@for (f of e.failed; track $index) {
|
||||
<div class="le-fail">✗ {{ f }}</div>
|
||||
}
|
||||
@for (p of e.passed; track $index) {
|
||||
<div class="le-pass">✓ {{ p }}</div>
|
||||
}
|
||||
@for (n of e.notes; track $index) {
|
||||
<div class="le-note">• {{ n }}</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6" class="pg-empty">No active loan products.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else {
|
||||
<p class="pg-sub le-help">
|
||||
Eligibility is not worked out for this borrower because a loan is overdue, NPA or written off.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul class="le-notes">
|
||||
@for (n of s.notes; track $index) {
|
||||
<li>{{ n }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.le-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.le-kpi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e4e7ec;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.le-kpi--good {
|
||||
border-left: 4px solid #12b76a;
|
||||
}
|
||||
|
||||
.le-kpi--bad {
|
||||
border-left: 4px solid #d92d20;
|
||||
}
|
||||
|
||||
.le-kpi-l {
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.le-kpi-v {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.le-kpi-s {
|
||||
font-size: 13px;
|
||||
color: #475467;
|
||||
}
|
||||
|
||||
.le-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.le-dot--good {
|
||||
background: #12b76a;
|
||||
}
|
||||
|
||||
.le-dot--warn {
|
||||
background: #f79009;
|
||||
}
|
||||
|
||||
.le-dot--bad {
|
||||
background: #d92d20;
|
||||
}
|
||||
|
||||
.le-bar {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
height: 8px;
|
||||
margin-right: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #f2f4f7;
|
||||
vertical-align: middle;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #12b76a;
|
||||
}
|
||||
}
|
||||
|
||||
.le-bar--bad span {
|
||||
background: #d92d20;
|
||||
}
|
||||
|
||||
.le-muted {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.le-tabs {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.le-search {
|
||||
min-width: 220px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.le-help {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.le-chip {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.le-chip--good {
|
||||
background: #ecfdf3;
|
||||
color: #067647;
|
||||
}
|
||||
|
||||
.le-chip--watch {
|
||||
background: #fffaeb;
|
||||
color: #b54708;
|
||||
}
|
||||
|
||||
.le-chip--bad {
|
||||
background: #fef3f2;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.le-selected {
|
||||
background: #f5f8ff;
|
||||
}
|
||||
|
||||
.le-facts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
padding: 8px 16px 12px;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.le-why div {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.le-fail {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.le-pass {
|
||||
color: #067647;
|
||||
}
|
||||
|
||||
.le-note {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.le-error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.le-notes {
|
||||
margin: 8px 0 24px;
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.le-late {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.le-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
overflow-y: auto;
|
||||
background: rgb(15 23 42 / 55%);
|
||||
}
|
||||
|
||||
.le-modal {
|
||||
width: min(1100px, 100%);
|
||||
padding: 20px 24px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 20px 50px rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.le-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.le-views {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.le-compact td {
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.le-compact .pg-btn {
|
||||
padding: 4px 14px;
|
||||
}
|
||||
|
||||
.le-pager {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
|
||||
span {
|
||||
margin-right: auto;
|
||||
color: #667085;
|
||||
}
|
||||
}
|
||||
|
||||
.le-loader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 32px auto;
|
||||
padding: 36px 24px;
|
||||
max-width: 520px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 16px rgb(16 24 40 / 8%);
|
||||
}
|
||||
|
||||
.le-loader-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #1d2939;
|
||||
}
|
||||
|
||||
.le-loader-steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.9;
|
||||
color: #98a2b3;
|
||||
|
||||
.le-done {
|
||||
color: #067647;
|
||||
}
|
||||
|
||||
.le-now {
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.le-progress {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
background: #eaecf0;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #1d4ed8;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.le-spinner {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 4px solid #dbe4ff;
|
||||
border-top-color: #1d4ed8;
|
||||
border-radius: 50%;
|
||||
animation: le-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.le-spinner--sm {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-width: 2px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes le-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.le-total td {
|
||||
font-weight: 600;
|
||||
border-top: 2px solid #d0d5dd;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user