Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fca550f4c4 | ||
|
|
3e5cc26e49 | ||
|
|
c3713f8a90 | ||
|
|
f804d356d7 | ||
|
|
1daa8e60a0 | ||
|
|
4d205672df |
@@ -10,8 +10,7 @@
|
|||||||
|
|
||||||
/** Angular Imports */
|
/** Angular Imports */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
|
|
||||||
|
|
||||||
/** rxjs Imports */
|
/** rxjs Imports */
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
@@ -199,16 +198,6 @@ export class AccountingService {
|
|||||||
return this.http.get('/journalentries/ledger', { params: httpParams });
|
return this.http.get('/journalentries/ledger', { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The whole transfer a ledger transaction belongs to, when it is one branch's part of a transfer between branches.
|
|
||||||
* An extra, informational lookup: if it fails the screen simply shows no banner, so a failure raises no error popup.
|
|
||||||
*/
|
|
||||||
getInterBranchTransfer(transactionId: string): Observable<any> {
|
|
||||||
return this.http.get(`/accounting/interbranchtransfers/${transactionId}`, {
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} transactionId Transaction ID to be reverted.
|
* @param {string} transactionId Transaction ID to be reverted.
|
||||||
* @param {string} comments Any comments.
|
* @param {string} comments Any comments.
|
||||||
|
|||||||
@@ -139,13 +139,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="limit">
|
|
||||||
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Sanctioned Limit</th>
|
|
||||||
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
|
|
||||||
{{ getCurrencySymbol(row) }} {{ row.overdraftLimit | formatNumber }}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="balance">
|
<ng-container matColumnDef="balance">
|
||||||
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
|
<th mat-header-cell *matHeaderCellDef class="acl-th acl-th--right">Balance</th>
|
||||||
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
|
<td mat-cell *matCellDef="let row" class="acl-td acl-td--right">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { FormatNumberPipe } from '../../pipes/format-number.pipe';
|
|||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { AccountsService } from '../accounts.service';
|
import { AccountsService } from '../accounts.service';
|
||||||
|
|
||||||
export type AccountType = 'savings' | 'current' | 'cc' | 'fixed-deposits' | 'recurring-deposits';
|
export type AccountType = 'savings' | 'current' | 'fixed-deposits' | 'recurring-deposits';
|
||||||
|
|
||||||
interface AccountConfig {
|
interface AccountConfig {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -119,13 +119,6 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
typeLabel: 'CA',
|
typeLabel: 'CA',
|
||||||
typeBadgeClass: 'dtype--ca'
|
typeBadgeClass: 'dtype--ca'
|
||||||
},
|
},
|
||||||
cc: {
|
|
||||||
label: 'CC Accounts',
|
|
||||||
icon: 'hand-holding-usd',
|
|
||||||
accentClass: 'accent--current',
|
|
||||||
typeLabel: 'CC',
|
|
||||||
typeBadgeClass: 'dtype--ca'
|
|
||||||
},
|
|
||||||
'fixed-deposits': {
|
'fixed-deposits': {
|
||||||
label: 'Fixed Deposit Accounts',
|
label: 'Fixed Deposit Accounts',
|
||||||
icon: 'lock',
|
icon: 'lock',
|
||||||
@@ -149,24 +142,6 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => {
|
this.route.data.pipe(takeUntil(this.destroy$)).subscribe((data) => {
|
||||||
this.accountType = data['accountType'] as AccountType;
|
this.accountType = data['accountType'] as AccountType;
|
||||||
this.displayedColumns = this.accountType === 'cc' ? [
|
|
||||||
'accountNo',
|
|
||||||
'depositType',
|
|
||||||
'clientName',
|
|
||||||
'productName',
|
|
||||||
'limit',
|
|
||||||
'balance',
|
|
||||||
'status',
|
|
||||||
'actions'
|
|
||||||
] : [
|
|
||||||
'accountNo',
|
|
||||||
'depositType',
|
|
||||||
'clientName',
|
|
||||||
'productName',
|
|
||||||
'balance',
|
|
||||||
'status',
|
|
||||||
'actions'
|
|
||||||
];
|
|
||||||
this.pageIndex = 0;
|
this.pageIndex = 0;
|
||||||
this.searchControl.setValue('', { emitEvent: false });
|
this.searchControl.setValue('', { emitEvent: false });
|
||||||
this.loadAccounts();
|
this.loadAccounts();
|
||||||
@@ -208,12 +183,9 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
const toVal = this.toDateControl.value;
|
const toVal = this.toDateControl.value;
|
||||||
const bothDatesSet = !!(fromVal && toVal);
|
const bothDatesSet = !!(fromVal && toVal);
|
||||||
|
|
||||||
// CC accounts come from the server already narrowed to those that allow an overdraft; nothing else is read.
|
|
||||||
const isCc = this.accountType === 'cc';
|
|
||||||
const params: import('../accounts.service').AccountListParams = {
|
const params: import('../accounts.service').AccountListParams = {
|
||||||
offset: isCc ? 0 : this.pageIndex * this.pageSize,
|
offset: this.pageIndex * this.pageSize,
|
||||||
limit: isCc ? 500 : this.pageSize,
|
limit: this.pageSize,
|
||||||
...(isCc && { overdraftOnly: true }),
|
|
||||||
accountNo: this.searchType === 'accountNo' ? search || undefined : undefined,
|
accountNo: this.searchType === 'accountNo' ? search || undefined : undefined,
|
||||||
clientName: this.searchType === 'clientName' ? search || undefined : undefined,
|
clientName: this.searchType === 'clientName' ? search || undefined : undefined,
|
||||||
orderBy: 'id',
|
orderBy: 'id',
|
||||||
@@ -227,7 +199,7 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
const obs$ =
|
const obs$ =
|
||||||
this.accountType === 'savings'
|
this.accountType === 'savings'
|
||||||
? this.accountsService.getSavingsAccounts(params)
|
? this.accountsService.getSavingsAccounts(params)
|
||||||
: this.accountType === 'current' || this.accountType === 'cc'
|
: this.accountType === 'current'
|
||||||
? this.accountsService.getCurrentAccounts(params)
|
? this.accountsService.getCurrentAccounts(params)
|
||||||
: this.accountType === 'fixed-deposits'
|
: this.accountType === 'fixed-deposits'
|
||||||
? this.accountsService.getFixedDepositAccounts(params)
|
? this.accountsService.getFixedDepositAccounts(params)
|
||||||
@@ -267,7 +239,7 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.totalRecords = isCc || Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length);
|
this.totalRecords = Array.isArray(res) ? items.length : (res?.totalFilteredRecords ?? items.length);
|
||||||
this.dataSource.data = items;
|
this.dataSource.data = items;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
},
|
},
|
||||||
@@ -335,13 +307,6 @@ export class AccountsListComponent implements OnInit, OnDestroy {
|
|||||||
id,
|
id,
|
||||||
'general'
|
'general'
|
||||||
],
|
],
|
||||||
cc: [
|
|
||||||
'/clients',
|
|
||||||
account.clientId,
|
|
||||||
'current-accounts',
|
|
||||||
id,
|
|
||||||
'general'
|
|
||||||
],
|
|
||||||
'fixed-deposits': [
|
'fixed-deposits': [
|
||||||
'/clients',
|
'/clients',
|
||||||
account.clientId,
|
account.clientId,
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ const routes: Routes = [
|
|||||||
component: AccountsListComponent,
|
component: AccountsListComponent,
|
||||||
data: { accountType: 'current', title: 'Current Accounts', breadcrumb: 'Current Accounts' }
|
data: { accountType: 'current', title: 'Current Accounts', breadcrumb: 'Current Accounts' }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// Cash Credit: current accounts opened on a product that allows an overdraft.
|
|
||||||
path: 'cc',
|
|
||||||
component: AccountsListComponent,
|
|
||||||
data: { accountType: 'cc', title: 'CC Accounts', breadcrumb: 'CC Accounts' }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'fixed-deposits',
|
path: 'fixed-deposits',
|
||||||
component: AccountsListComponent,
|
component: AccountsListComponent,
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ export interface AccountListParams {
|
|||||||
fromDate?: string;
|
fromDate?: string;
|
||||||
toDate?: string;
|
toDate?: string;
|
||||||
asOnDate?: string;
|
asOnDate?: string;
|
||||||
/** Current accounts only: just the Cash Credit (overdraft) accounts, filtered by the server. */
|
|
||||||
overdraftOnly?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -57,7 +55,6 @@ export class AccountsService {
|
|||||||
if (params.fromDate) httpParams = httpParams.set('fromDate', params.fromDate);
|
if (params.fromDate) httpParams = httpParams.set('fromDate', params.fromDate);
|
||||||
if (params.toDate) httpParams = httpParams.set('toDate', params.toDate);
|
if (params.toDate) httpParams = httpParams.set('toDate', params.toDate);
|
||||||
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
|
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
|
||||||
if (params.overdraftOnly) httpParams = httpParams.set('overdraftOnly', 'true');
|
|
||||||
return this.http.get('/currentaccounts', { params: httpParams });
|
return this.http.get('/currentaccounts', { params: httpParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -142,32 +142,6 @@ export class AmlService {
|
|||||||
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
|
return this.http.get<any[]>('/aml/ctr-reports/lines', { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cash transactions of the threshold or more that no generated report contains yet, shaped like the lines above.
|
|
||||||
* They show on the FIU screen as soon as they are posted; generating the month is what files them.
|
|
||||||
*/
|
|
||||||
getCtrPendingLines(): Observable<any[]> {
|
|
||||||
return this.http.get<any[]>('/aml/ctr-reports/pending-lines');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How much came into and went out of each current / Cash Credit account over the last `days` days, with the
|
|
||||||
* customer's declared turnover and the sanctioned limit alongside.
|
|
||||||
*/
|
|
||||||
getCcTracking(days = 30): Observable<any> {
|
|
||||||
return this.http.get<any>('/aml/cc-tracking', { params: new HttpParams().set('days', String(days)) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every credit and debit on one CC account over the last `days` days, with the mode and counterparty each payment
|
|
||||||
* recorded, totalled by mode and by counterparty.
|
|
||||||
*/
|
|
||||||
getCcAccountTrail(accountId: number, days = 30): Observable<any> {
|
|
||||||
return this.http.get<any>(`/aml/cc-tracking/${accountId}/trail`, {
|
|
||||||
params: new HttpParams().set('days', String(days))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** month format: yyyy-MM */
|
/** month format: yyyy-MM */
|
||||||
generateCtrReport(month: string): Observable<any> {
|
generateCtrReport(month: string): Observable<any> {
|
||||||
return this.http.post('/aml/ctr-reports/generate', { month });
|
return this.http.post('/aml/ctr-reports/generate', { month });
|
||||||
|
|||||||
@@ -1,778 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright since 2025 Mifos Initiative
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ApiMappingRow, KeyValue, kv } from './api-management.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads what a vendor's documentation or a Postman / browser / cURL capture gives - in any of these shapes - and turns
|
|
||||||
* it into the pieces of an API: method, URL, path, query parameters, headers, content type, payload, response status,
|
|
||||||
* response headers, response body, and a first guess at the CBS field mapping. Nothing is sent anywhere.
|
|
||||||
*
|
|
||||||
* Understood:
|
|
||||||
* - a cURL command
|
|
||||||
* - a Postman collection or a single Postman request / item (with its saved responses)
|
|
||||||
* - a HAR capture
|
|
||||||
* - an "API definition" object: { method, url | path, query, headers, body, status, ... }
|
|
||||||
* - a bare JSON payload (treated as the request body, or the response body on the response side)
|
|
||||||
* - form text (a=1&b=2) and other raw text
|
|
||||||
*
|
|
||||||
* Credentials found in headers or query strings are never kept: the value is replaced by {{secret}} and the row is
|
|
||||||
* flagged as secret, so a key pasted from a sample cannot end up stored.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ImportedRequest {
|
|
||||||
source: string;
|
|
||||||
method?: string;
|
|
||||||
/** The full URL when the file had one. */
|
|
||||||
url?: string;
|
|
||||||
/** Scheme and host of the URL, when present. */
|
|
||||||
baseUrl?: string;
|
|
||||||
path?: string;
|
|
||||||
queryParams: KeyValue[];
|
|
||||||
headers: KeyValue[];
|
|
||||||
contentType?: string;
|
|
||||||
body?: string;
|
|
||||||
/** A response found in the same file (Postman saved response, HAR entry, definition object). */
|
|
||||||
response?: ImportedResponse;
|
|
||||||
notes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportedResponse {
|
|
||||||
source: string;
|
|
||||||
status?: number;
|
|
||||||
headers: KeyValue[];
|
|
||||||
body?: string;
|
|
||||||
errorBody?: string;
|
|
||||||
notes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECRET_HEADERS =
|
|
||||||
/^(authorization|proxy-authorization|x-api-key|api-key|apikey|authkey|x-auth-token|auth-token|token|x-access-token|cookie|set-cookie|x-hub-signature(-256)?)$/i;
|
|
||||||
const SECRET_QUERY = /^(key|api[-_]?key|apikey|token|access[-_]?token|auth[-_]?key|secret|password|signature)$/i;
|
|
||||||
const SECRET = '{{secret}}';
|
|
||||||
|
|
||||||
// ── small helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function isObject(v: unknown): v is Record<string, any> {
|
|
||||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryJson(text: string): any {
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function prettyJsonOrText(text: string): string {
|
|
||||||
const parsed = tryJson(text);
|
|
||||||
return parsed !== undefined && typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bodyToText(body: unknown): string | undefined {
|
|
||||||
if (body === undefined || body === null) return undefined;
|
|
||||||
if (typeof body === 'string') return prettyJsonOrText(body);
|
|
||||||
return JSON.stringify(body, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toKeyValues(source: unknown, kind: 'header' | 'query' | 'plain'): KeyValue[] {
|
|
||||||
const out: KeyValue[] = [];
|
|
||||||
const add = (key: unknown, value: unknown, description = '', disabled = false) => {
|
|
||||||
const k = String(key ?? '').trim();
|
|
||||||
if (!k) return;
|
|
||||||
const secret = (kind === 'header' && SECRET_HEADERS.test(k)) || (kind === 'query' && SECRET_QUERY.test(k));
|
|
||||||
out.push({
|
|
||||||
key: k,
|
|
||||||
value: secret ? SECRET : value === undefined || value === null ? '' : String(value),
|
|
||||||
description,
|
|
||||||
enabled: !disabled,
|
|
||||||
secret
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if (Array.isArray(source)) {
|
|
||||||
for (const item of source) {
|
|
||||||
if (isObject(item))
|
|
||||||
add(item.key ?? item.name, item.value, String(item.description ?? ''), item.disabled === true);
|
|
||||||
}
|
|
||||||
} else if (isObject(source)) {
|
|
||||||
for (const [
|
|
||||||
k,
|
|
||||||
v
|
|
||||||
] of Object.entries(source)) add(k, v);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Splits a URL into its base, path and query without failing on {{placeholders}} or {path} parameters. */
|
|
||||||
function splitUrl(raw: string): { baseUrl?: string; path: string; query: KeyValue[] } {
|
|
||||||
let rest = raw.trim();
|
|
||||||
let baseUrl: string | undefined;
|
|
||||||
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#]+)(.*)$/.exec(rest);
|
|
||||||
if (m) {
|
|
||||||
baseUrl = m[1];
|
|
||||||
rest = m[2];
|
|
||||||
}
|
|
||||||
const hash = rest.indexOf('#');
|
|
||||||
if (hash >= 0) rest = rest.slice(0, hash);
|
|
||||||
let queryText = '';
|
|
||||||
const q = rest.indexOf('?');
|
|
||||||
if (q >= 0) {
|
|
||||||
queryText = rest.slice(q + 1);
|
|
||||||
rest = rest.slice(0, q);
|
|
||||||
}
|
|
||||||
const query: KeyValue[] = [];
|
|
||||||
for (const part of queryText.split('&')) {
|
|
||||||
if (!part) continue;
|
|
||||||
const eq = part.indexOf('=');
|
|
||||||
const key = safeDecode(eq >= 0 ? part.slice(0, eq) : part);
|
|
||||||
const value = eq >= 0 ? safeDecode(part.slice(eq + 1)) : '';
|
|
||||||
query.push(...toKeyValues([{ key, value }], 'query'));
|
|
||||||
}
|
|
||||||
return { baseUrl, path: rest || '/', query };
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeDecode(s: string): string {
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(s.replace(/\+/g, ' '));
|
|
||||||
} catch {
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectContentType(
|
|
||||||
headers: KeyValue[],
|
|
||||||
body: string | undefined
|
|
||||||
): { contentType?: string; headers: KeyValue[] } {
|
|
||||||
const ct = headers.find((h) => h.key.toLowerCase() === 'content-type');
|
|
||||||
const rest = headers.filter((h) => h.key.toLowerCase() !== 'content-type');
|
|
||||||
if (ct) return { contentType: ct.value.split(';')[0].trim(), headers: rest };
|
|
||||||
if (!body) return { headers: rest };
|
|
||||||
if (tryJson(body) !== undefined) return { contentType: 'application/json', headers: rest };
|
|
||||||
if (/^\s*</.test(body)) return { contentType: 'application/xml', headers: rest };
|
|
||||||
if (/^[^\s=&]+=[^&]*(&[^\s=&]+=[^&]*)*$/.test(body.trim()))
|
|
||||||
return { contentType: 'application/x-www-form-urlencoded', headers: rest };
|
|
||||||
return { contentType: 'text/plain', headers: rest };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything about a request is derived the same way once method / url / headers / body are known. */
|
|
||||||
function finishRequest(
|
|
||||||
source: string,
|
|
||||||
method: string | undefined,
|
|
||||||
url: string | undefined,
|
|
||||||
pathOnly: string | undefined,
|
|
||||||
query: unknown,
|
|
||||||
headers: KeyValue[],
|
|
||||||
body: string | undefined,
|
|
||||||
contentTypeHint: string | undefined,
|
|
||||||
notes: string[]
|
|
||||||
): ImportedRequest {
|
|
||||||
const parts = url ? splitUrl(url) : pathOnly ? splitUrl(pathOnly) : undefined;
|
|
||||||
const queryParams = [
|
|
||||||
...(parts?.query ?? []),
|
|
||||||
...toKeyValues(query, 'query')
|
|
||||||
];
|
|
||||||
const detected = detectContentType(headers, body);
|
|
||||||
if (queryParams.some((q) => q.secret))
|
|
||||||
notes.push('A credential in the query string was replaced by {{secret}} - set it on the Connection tab.');
|
|
||||||
if (headers.some((h) => h.secret))
|
|
||||||
notes.push('Credentials in the headers were replaced by {{secret}} - set them on the Connection tab.');
|
|
||||||
return {
|
|
||||||
source,
|
|
||||||
method: method ? method.toUpperCase() : body ? 'POST' : undefined,
|
|
||||||
url,
|
|
||||||
baseUrl: parts?.baseUrl,
|
|
||||||
path: parts?.path,
|
|
||||||
queryParams,
|
|
||||||
headers: detected.headers,
|
|
||||||
contentType: contentTypeHint ?? detected.contentType,
|
|
||||||
body,
|
|
||||||
notes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── cURL ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Splits a shell command into words, honouring '…' "…" quotes and \ line continuations. */
|
|
||||||
function shellWords(command: string): string[] {
|
|
||||||
const words: string[] = [];
|
|
||||||
let cur = '';
|
|
||||||
let quote: '"' | "'" | null = null;
|
|
||||||
let started = false;
|
|
||||||
const text = command.replace(/\\\r?\n/g, ' ').trim();
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
const c = text[i];
|
|
||||||
if (quote) {
|
|
||||||
if (c === quote) quote = null;
|
|
||||||
else if (c === '\\' && quote === '"' && i + 1 < text.length) cur += text[++i];
|
|
||||||
else cur += c;
|
|
||||||
} else if (c === '"' || c === "'") {
|
|
||||||
quote = c;
|
|
||||||
started = true;
|
|
||||||
} else if (/\s/.test(c)) {
|
|
||||||
if (started || cur) words.push(cur);
|
|
||||||
cur = '';
|
|
||||||
started = false;
|
|
||||||
} else if (c === '\\' && i + 1 < text.length) {
|
|
||||||
cur += text[++i];
|
|
||||||
} else {
|
|
||||||
cur += c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (started || cur) words.push(cur);
|
|
||||||
return words;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCurl(text: string): ImportedRequest {
|
|
||||||
const words = shellWords(text);
|
|
||||||
const notes: string[] = [];
|
|
||||||
let method: string | undefined;
|
|
||||||
let url: string | undefined;
|
|
||||||
const headers: KeyValue[] = [];
|
|
||||||
const data: string[] = [];
|
|
||||||
let form = false;
|
|
||||||
let getMode = false;
|
|
||||||
for (let i = 0; i < words.length; i++) {
|
|
||||||
const w = words[i];
|
|
||||||
const next = () => words[++i] ?? '';
|
|
||||||
if (w === 'curl') continue;
|
|
||||||
if (w === '-X' || w === '--request') method = next();
|
|
||||||
else if (w === '-H' || w === '--header') {
|
|
||||||
const h = next();
|
|
||||||
const c = h.indexOf(':');
|
|
||||||
if (c > 0) headers.push(...toKeyValues([{ key: h.slice(0, c), value: h.slice(c + 1).trim() }], 'header'));
|
|
||||||
} else if ([
|
|
||||||
'-d',
|
|
||||||
'--data',
|
|
||||||
'--data-raw',
|
|
||||||
'--data-binary',
|
|
||||||
'--data-ascii'
|
|
||||||
].includes(w)) data.push(next());
|
|
||||||
else if (w === '--data-urlencode') {
|
|
||||||
data.push(next());
|
|
||||||
form = true;
|
|
||||||
} else if (w === '-F' || w === '--form') {
|
|
||||||
data.push(next());
|
|
||||||
form = true;
|
|
||||||
notes.push('Multipart form fields were read as name=value pairs.');
|
|
||||||
} else if (w === '--url') url = next();
|
|
||||||
else if (w === '-G' || w === '--get') getMode = true;
|
|
||||||
else if (w === '-u' || w === '--user') {
|
|
||||||
next();
|
|
||||||
headers.push({
|
|
||||||
key: 'Authorization',
|
|
||||||
value: SECRET,
|
|
||||||
description: 'Basic authentication from the cURL -u option',
|
|
||||||
enabled: true,
|
|
||||||
secret: true
|
|
||||||
});
|
|
||||||
} else if (w.startsWith('-')) {
|
|
||||||
// Flags that take a value we do not need.
|
|
||||||
if ([
|
|
||||||
'-o',
|
|
||||||
'--output',
|
|
||||||
'-A',
|
|
||||||
'--user-agent',
|
|
||||||
'-e',
|
|
||||||
'--referer',
|
|
||||||
'--connect-timeout',
|
|
||||||
'-m',
|
|
||||||
'--max-time',
|
|
||||||
'--proxy',
|
|
||||||
'-x'
|
|
||||||
].includes(w)) next();
|
|
||||||
} else if (!url && /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(w)) url = w;
|
|
||||||
else if (!url && /^[\w.-]+(:\d+)?\//.test(w)) url = w;
|
|
||||||
}
|
|
||||||
let body = data.length ? data.join('&') : undefined;
|
|
||||||
if (body && getMode) {
|
|
||||||
url = url + (url?.includes('?') ? '&' : '?') + body;
|
|
||||||
body = undefined;
|
|
||||||
}
|
|
||||||
return finishRequest(
|
|
||||||
'cURL command',
|
|
||||||
method ?? (getMode ? 'GET' : body ? 'POST' : 'GET'),
|
|
||||||
url,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
headers,
|
|
||||||
body ? prettyJsonOrText(body) : undefined,
|
|
||||||
form && !headers.some((h) => h.key.toLowerCase() === 'content-type')
|
|
||||||
? 'application/x-www-form-urlencoded'
|
|
||||||
: undefined,
|
|
||||||
notes
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Postman / HAR / definition object ───────────────────────────────────────
|
|
||||||
|
|
||||||
function findPostmanItem(node: any): any {
|
|
||||||
if (!node) return undefined;
|
|
||||||
if (node.request) return node;
|
|
||||||
if (Array.isArray(node.item)) {
|
|
||||||
for (const child of node.item) {
|
|
||||||
const found = findPostmanItem(child);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function postmanUrl(url: any): string | undefined {
|
|
||||||
if (!url) return undefined;
|
|
||||||
if (typeof url === 'string') return url;
|
|
||||||
if (typeof url.raw === 'string') return url.raw;
|
|
||||||
const host = Array.isArray(url.host) ? url.host.join('.') : url.host;
|
|
||||||
const path = Array.isArray(url.path) ? url.path.join('/') : url.path;
|
|
||||||
return `${url.protocol ? url.protocol + '://' : ''}${host ?? ''}/${path ?? ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function postmanResponse(r: any): ImportedResponse {
|
|
||||||
return {
|
|
||||||
source: 'Postman saved response',
|
|
||||||
status: typeof r?.code === 'number' ? r.code : undefined,
|
|
||||||
headers: toKeyValues(r?.header, 'header'),
|
|
||||||
body: bodyToText(r?.body),
|
|
||||||
notes: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromPostman(item: any): ImportedRequest {
|
|
||||||
const req = item.request;
|
|
||||||
const notes: string[] = [];
|
|
||||||
let body: string | undefined;
|
|
||||||
let contentType: string | undefined;
|
|
||||||
const b = req?.body;
|
|
||||||
if (b?.mode === 'raw') {
|
|
||||||
body = bodyToText(b.raw);
|
|
||||||
const lang = b.options?.raw?.language;
|
|
||||||
if (lang === 'json') contentType = 'application/json';
|
|
||||||
} else if (b?.mode === 'urlencoded') {
|
|
||||||
body = (b.urlencoded ?? [])
|
|
||||||
.filter((p: any) => !p.disabled)
|
|
||||||
.map((p: any) => `${encodeURIComponent(p.key)}=${encodeURIComponent(p.value ?? '')}`)
|
|
||||||
.join('&');
|
|
||||||
contentType = 'application/x-www-form-urlencoded';
|
|
||||||
} else if (b?.mode === 'formdata') {
|
|
||||||
body = (b.formdata ?? []).map((p: any) => `${p.key}=${p.value ?? ''}`).join('&');
|
|
||||||
contentType = 'multipart/form-data';
|
|
||||||
}
|
|
||||||
const request = finishRequest(
|
|
||||||
'Postman request',
|
|
||||||
typeof req?.method === 'string' ? req.method : undefined,
|
|
||||||
postmanUrl(req?.url),
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
toKeyValues(req?.header, 'header'),
|
|
||||||
body,
|
|
||||||
contentType,
|
|
||||||
notes
|
|
||||||
);
|
|
||||||
if (Array.isArray(item.response) && item.response.length) request.response = postmanResponse(item.response[0]);
|
|
||||||
if (req?.auth)
|
|
||||||
notes.push('The request had an auth section; it was not read. Set authentication on the Connection tab.');
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromHar(har: any): ImportedRequest {
|
|
||||||
const entry = har.log.entries[0];
|
|
||||||
const r = entry.request ?? {};
|
|
||||||
const body = r.postData?.text ? prettyJsonOrText(r.postData.text) : undefined;
|
|
||||||
const request = finishRequest(
|
|
||||||
'HAR capture',
|
|
||||||
r.method,
|
|
||||||
r.url,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
toKeyValues(r.headers, 'header'),
|
|
||||||
body,
|
|
||||||
typeof r.postData?.mimeType === 'string' ? r.postData.mimeType.split(';')[0] : undefined,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const res = entry.response;
|
|
||||||
if (res) {
|
|
||||||
request.response = {
|
|
||||||
source: 'HAR capture',
|
|
||||||
status: typeof res.status === 'number' ? res.status : undefined,
|
|
||||||
headers: toKeyValues(res.headers, 'header'),
|
|
||||||
body: res.content?.text ? prettyJsonOrText(res.content.text) : undefined,
|
|
||||||
notes: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
function definitionResponse(def: any): ImportedResponse | undefined {
|
|
||||||
const r = def.response ?? def.responseDefinition;
|
|
||||||
const status = def.status ?? def.statusCode ?? def.successCode ?? r?.status ?? r?.statusCode ?? r?.code;
|
|
||||||
const headers = def.responseHeaders ?? r?.headers;
|
|
||||||
const body = def.responseBody ?? (isObject(r) ? (r.body ?? r.data) : r);
|
|
||||||
const errorBody = def.errorBody ?? def.errorResponse ?? r?.errorBody;
|
|
||||||
if (status === undefined && headers === undefined && body === undefined && errorBody === undefined) return undefined;
|
|
||||||
return {
|
|
||||||
source: 'API definition',
|
|
||||||
status: typeof status === 'number' ? status : status ? Number(status) || undefined : undefined,
|
|
||||||
headers: toKeyValues(headers, 'header'),
|
|
||||||
body: bodyToText(body),
|
|
||||||
errorBody: bodyToText(errorBody),
|
|
||||||
notes: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fromDefinition(def: any): ImportedRequest {
|
|
||||||
const body = def.body ?? def.payload ?? def.data ?? def.requestBody ?? def.request;
|
|
||||||
const bodyText = isObject(body) && ('method' in body || 'url' in body) ? undefined : bodyToText(body);
|
|
||||||
const url = def.url ?? def.endpoint ?? def.uri;
|
|
||||||
const request = finishRequest(
|
|
||||||
'API definition',
|
|
||||||
def.method ?? def.httpMethod,
|
|
||||||
typeof url === 'string' ? url : undefined,
|
|
||||||
typeof def.path === 'string' ? def.path : undefined,
|
|
||||||
def.query ?? def.queryParams ?? def.params,
|
|
||||||
toKeyValues(def.headers, 'header'),
|
|
||||||
bodyText,
|
|
||||||
typeof def.contentType === 'string' ? def.contentType : undefined,
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
request.response = definitionResponse(def);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── public: request side ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function parseRequestText(text: string): ImportedRequest {
|
|
||||||
const t = (text ?? '').trim();
|
|
||||||
if (!t) throw new Error('Nothing to read - paste the request or choose a file.');
|
|
||||||
if (/^curl\b/i.test(t)) return parseCurl(t);
|
|
||||||
|
|
||||||
const parsed = tryJson(t);
|
|
||||||
if (parsed === undefined) {
|
|
||||||
// Not JSON: a cURL command in the middle of text, form data, or other raw text.
|
|
||||||
const curl = /curl\b[\s\S]*/i.exec(t);
|
|
||||||
if (curl && /https?:\/\//.test(curl[0])) return parseCurl(curl[0]);
|
|
||||||
const detected = detectContentType([], t);
|
|
||||||
return finishRequest('Raw text', undefined, undefined, undefined, undefined, [], t, detected.contentType, [
|
|
||||||
'The text is not JSON; it was kept as the request payload.'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if (isObject(parsed) && isObject(parsed.log) && Array.isArray(parsed.log.entries) && parsed.log.entries.length)
|
|
||||||
return fromHar(parsed);
|
|
||||||
if (isObject(parsed) && (parsed.item || parsed.info?.schema)) {
|
|
||||||
const item = findPostmanItem(parsed);
|
|
||||||
if (!item) throw new Error('The Postman collection has no request in it.');
|
|
||||||
return fromPostman(item);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
isObject(parsed) &&
|
|
||||||
isObject(parsed.request) &&
|
|
||||||
(parsed.request.url || parsed.request.method || parsed.request.header)
|
|
||||||
)
|
|
||||||
return fromPostman(parsed);
|
|
||||||
if (isObject(parsed) && [
|
|
||||||
'method',
|
|
||||||
'httpMethod',
|
|
||||||
'url',
|
|
||||||
'endpoint',
|
|
||||||
'uri',
|
|
||||||
'headers',
|
|
||||||
'queryParams',
|
|
||||||
'requestBody',
|
|
||||||
'payload'
|
|
||||||
].some((k) => k in parsed) && !('statuses' in parsed)) {
|
|
||||||
return fromDefinition(parsed);
|
|
||||||
}
|
|
||||||
// A bare JSON payload.
|
|
||||||
return finishRequest(
|
|
||||||
'JSON payload',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
[],
|
|
||||||
JSON.stringify(parsed, null, 2),
|
|
||||||
'application/json',
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── public: response side ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function parseResponseText(text: string): ImportedResponse {
|
|
||||||
const t = (text ?? '').trim();
|
|
||||||
if (!t) throw new Error('Nothing to read - paste the response or choose a file.');
|
|
||||||
const parsed = tryJson(t);
|
|
||||||
if (parsed === undefined) {
|
|
||||||
return {
|
|
||||||
source: 'Raw text',
|
|
||||||
headers: [],
|
|
||||||
body: t,
|
|
||||||
notes: ['The text is not JSON; it was kept as the response body.']
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (isObject(parsed) && isObject(parsed.log) && Array.isArray(parsed.log.entries) && parsed.log.entries.length) {
|
|
||||||
return (
|
|
||||||
fromHar(parsed).response ?? { source: 'HAR capture', headers: [], notes: ['The HAR entry has no response.'] }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (isObject(parsed) && (parsed.item || parsed.info?.schema)) {
|
|
||||||
const item = findPostmanItem(parsed);
|
|
||||||
const res = item ? fromPostman(item).response : undefined;
|
|
||||||
if (!res) throw new Error('The Postman collection has no saved response.');
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
if (isObject(parsed) && Array.isArray(parsed.response) && parsed.response.length && (parsed.request || parsed.name)) {
|
|
||||||
return postmanResponse(parsed.response[0]);
|
|
||||||
}
|
|
||||||
if (isObject(parsed) && [
|
|
||||||
'status',
|
|
||||||
'statusCode',
|
|
||||||
'headers',
|
|
||||||
'responseHeaders',
|
|
||||||
'responseBody',
|
|
||||||
'errorBody'
|
|
||||||
].some(
|
|
||||||
(k) => k in parsed
|
|
||||||
) && ('body' in parsed || 'responseBody' in parsed || 'headers' in parsed || 'responseHeaders' in parsed || 'errorBody' in parsed || 'data' in parsed)) {
|
|
||||||
const r = definitionResponse({
|
|
||||||
status: parsed.status ?? parsed.statusCode,
|
|
||||||
responseHeaders: parsed.responseHeaders ?? parsed.headers,
|
|
||||||
responseBody: parsed.responseBody ?? parsed.body ?? parsed.data,
|
|
||||||
errorBody: parsed.errorBody
|
|
||||||
});
|
|
||||||
if (r) return r;
|
|
||||||
}
|
|
||||||
return { source: 'JSON body', headers: [], body: JSON.stringify(parsed, null, 2), notes: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── JSON → mapping rows ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface JsonLeaf {
|
|
||||||
path: string;
|
|
||||||
value: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every value in a JSON document with its path: `a.b[0].c`. Only the first item of an array is walked. */
|
|
||||||
export function flatten(json: unknown, prefix = ''): JsonLeaf[] {
|
|
||||||
const out: JsonLeaf[] = [];
|
|
||||||
if (Array.isArray(json)) {
|
|
||||||
if (json.length) out.push(...flatten(json[0], `${prefix}[0]`));
|
|
||||||
} else if (isObject(json)) {
|
|
||||||
for (const [
|
|
||||||
k,
|
|
||||||
v
|
|
||||||
] of Object.entries(json)) {
|
|
||||||
out.push(...flatten(v, prefix ? `${prefix}.${k}` : k));
|
|
||||||
}
|
|
||||||
} else if (prefix) {
|
|
||||||
out.push({ path: prefix, value: json });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lastKey(path: string): string {
|
|
||||||
const m = /([A-Za-z0-9_$-]+)(\[\d+\])*$/.exec(path);
|
|
||||||
return m ? m[1] : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
||||||
|
|
||||||
const REQUEST_GUESS: [RegExp, string, boolean][] = [
|
|
||||||
[
|
|
||||||
/^(mobile|mobiles|mobileno|mobilenumber|msisdn|phone|phoneno|phonenumber|to|destination|recipient|recipients|number|contact)$/,
|
|
||||||
'customer.mobile',
|
|
||||||
true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(templateid|template|templatename|dlttemplateid|flowid)$/,
|
|
||||||
'message.templateId',
|
|
||||||
true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(message|text|body|content|sms|msg|smstext|var1|messagetext)$/,
|
|
||||||
'message.text',
|
|
||||||
true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(sender|senderid|from|source|shortcode)$/,
|
|
||||||
'bank.senderId',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(email|emailid|mail|emailaddress)$/,
|
|
||||||
'customer.email',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(customername|fullname|firstname|name)$/,
|
|
||||||
'customer.name',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(otp|code|pin)$/,
|
|
||||||
'message.otp',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(pan|pannumber)$/,
|
|
||||||
'customer.pan',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(aadhaar|aadhar|aadhaarnumber)$/,
|
|
||||||
'customer.aadhaar',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(dob|dateofbirth|birthdate)$/,
|
|
||||||
'customer.dob',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(membercode|memberid|memberreference)$/,
|
|
||||||
'bank.memberCode',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(accountno|accountnumber|acno|account)$/,
|
|
||||||
'account.number',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(cif|customerid|clientid|cifno)$/,
|
|
||||||
'customer.cif',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(amount|amt|txnamount|transactionamount)$/,
|
|
||||||
'txn.amount',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(txnid|transactionid|transactionreference|txnref)$/,
|
|
||||||
'txn.reference',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(txndate|transactiondate)$/,
|
|
||||||
'txn.date',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(fromdate|startdate|datefrom)$/,
|
|
||||||
'request.fromDate',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(todate|enddate|dateto)$/,
|
|
||||||
'request.toDate',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(reference|referenceno|referencenumber|ref|requestid|clientreference|correlationid)$/,
|
|
||||||
'request.reference',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(tenantidentifier|tenantid)$/,
|
|
||||||
'tenant.identifier',
|
|
||||||
false
|
|
||||||
],
|
|
||||||
[
|
|
||||||
/^(providerid)$/,
|
|
||||||
'sms.providerId',
|
|
||||||
false
|
|
||||||
]
|
|
||||||
];
|
|
||||||
|
|
||||||
function guessTransform(cbsField: string, value: unknown): string {
|
|
||||||
if (cbsField === 'customer.mobile') return 'DIGITS_ONLY';
|
|
||||||
if (cbsField === 'customer.email') return 'LOWER';
|
|
||||||
if (cbsField === 'bank.senderId') return 'UPPER';
|
|
||||||
if (cbsField === 'txn.amount') return 'NUMBER';
|
|
||||||
if (typeof value === 'boolean') return 'BOOLEAN';
|
|
||||||
if (typeof value === 'number') return 'NUMBER';
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
if (/^\d{8}$/.test(value) && cbsField.includes('Date')) return 'DATE_DDMMYYYY';
|
|
||||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return 'DATE_YYYY_MM_DD';
|
|
||||||
}
|
|
||||||
return 'NONE';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A first mapping for a request payload: each value that looks like a known CBS field (mobile, message, template,
|
|
||||||
* sender ...) becomes "CBS field → path"; every other value becomes a fixed value at its path, so the whole payload is
|
|
||||||
* reproduced. Review and adjust.
|
|
||||||
*/
|
|
||||||
export function guessRequestRows(bodyText: string): ApiMappingRow[] {
|
|
||||||
const parsed = tryJson(bodyText);
|
|
||||||
if (parsed === undefined || typeof parsed !== 'object' || parsed === null) return [];
|
|
||||||
const rows: ApiMappingRow[] = [];
|
|
||||||
const used = new Set<string>();
|
|
||||||
for (const leaf of flatten(parsed)) {
|
|
||||||
const key = norm(lastKey(leaf.path));
|
|
||||||
const hit = REQUEST_GUESS.find(([re]) => re.test(key));
|
|
||||||
if (hit && !used.has(hit[1])) {
|
|
||||||
used.add(hit[1]);
|
|
||||||
rows.push({
|
|
||||||
from: hit[1],
|
|
||||||
to: leaf.path,
|
|
||||||
transform: guessTransform(hit[1], leaf.value),
|
|
||||||
defaultValue: '',
|
|
||||||
mandatory: hit[2]
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
rows.push({
|
|
||||||
from: '',
|
|
||||||
to: leaf.path,
|
|
||||||
transform: guessTransform('', leaf.value),
|
|
||||||
defaultValue: leaf.value === null ? '' : String(leaf.value),
|
|
||||||
mandatory: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows.filter((r) => r.from || r.defaultValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A first mapping for a response body: every value at its path → a CBS field named after the vendor and the key
|
|
||||||
* (`sms.messageId`). Ids / references are marked required.
|
|
||||||
*/
|
|
||||||
export function guessResponseRows(bodyText: string, vendorCode: string): ApiMappingRow[] {
|
|
||||||
const parsed = tryJson(bodyText);
|
|
||||||
if (parsed === undefined || typeof parsed !== 'object' || parsed === null) return [];
|
|
||||||
const prefix = vendorCode.toLowerCase().split('_')[0] || 'vendor';
|
|
||||||
const rows: ApiMappingRow[] = [];
|
|
||||||
const used = new Set<string>();
|
|
||||||
for (const leaf of flatten(parsed)) {
|
|
||||||
const key = lastKey(leaf.path);
|
|
||||||
const n = norm(key);
|
|
||||||
let target: string;
|
|
||||||
if (/^(messageid|msgid|id|reference|requestid|referenceid|messageid)$/.test(n)) target = 'reference';
|
|
||||||
else if (/^(status|state|result|type)$/.test(n)) target = 'status';
|
|
||||||
else if (/^(error|errormessage|errormsg|reason|detail|description)$/.test(n)) target = 'error';
|
|
||||||
else target = key.replace(/[^A-Za-z0-9]+(.)?/g, (_m, c: string) => (c ? c.toUpperCase() : ''));
|
|
||||||
let to = `${prefix}.${target}`;
|
|
||||||
for (let i = 2; used.has(to); i++) to = `${prefix}.${target}${i}`;
|
|
||||||
used.add(to);
|
|
||||||
rows.push({
|
|
||||||
from: leaf.path,
|
|
||||||
to,
|
|
||||||
transform: target === 'status' ? 'UPPER' : guessTransform('', leaf.value),
|
|
||||||
defaultValue: '',
|
|
||||||
mandatory: target === 'reference'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function secretNote(rows: KeyValue[]): string | null {
|
|
||||||
const n = rows.filter((r) => r.secret).length;
|
|
||||||
return n ? `${n} credential value(s) were hidden.` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { kv };
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<!--
|
|
||||||
Copyright since 2025 Mifos Initiative
|
|
||||||
|
|
||||||
This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
@if (!rows.length) {
|
|
||||||
<div class="muted" style="font-size: 12px; margin-bottom: 6px">{{ emptyText }}</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="width: 34px">On</th>
|
|
||||||
<th>{{ keyLabel }}</th>
|
|
||||||
<th>{{ valueLabel }}</th>
|
|
||||||
<th>Note</th>
|
|
||||||
<th style="width: 60px">Secret</th>
|
|
||||||
@if (!fixedKeys) {
|
|
||||||
<th style="width: 40px"></th>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (r of rows; track $index; let i = $index) {
|
|
||||||
<tr>
|
|
||||||
<td style="text-align: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="r.enabled" style="width: auto" />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input class="cic-mono" [(ngModel)]="r.key" [placeholder]="keyPlaceholder" [readonly]="fixedKeys" />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
class="cic-mono"
|
|
||||||
[type]="r.secret ? 'password' : 'text'"
|
|
||||||
[(ngModel)]="r.value"
|
|
||||||
[placeholder]="valuePlaceholder"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td><input [(ngModel)]="r.description" /></td>
|
|
||||||
<td style="text-align: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="r.secret" style="width: auto" title="Show the value masked" />
|
|
||||||
</td>
|
|
||||||
@if (!fixedKeys) {
|
|
||||||
<td>
|
|
||||||
<button class="cic-btn sm danger" (click)="remove(i)">✕</button>
|
|
||||||
</td>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (!fixedKeys) {
|
|
||||||
<button class="cic-btn sm" style="margin-top: 8px" (click)="add()">{{ addLabel }}</button>
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright since 2025 Mifos Initiative
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { Component, Input } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
|
|
||||||
import { KeyValue } from '../api-management.service';
|
|
||||||
|
|
||||||
/** An editable table of name / value pairs: headers, query parameters, path parameters. Edits the array in place. */
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-api-key-value-editor',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule
|
|
||||||
],
|
|
||||||
templateUrl: './api-key-value-editor.component.html',
|
|
||||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class ApiKeyValueEditorComponent {
|
|
||||||
@Input() rows: KeyValue[] = [];
|
|
||||||
@Input() keyLabel = 'Name';
|
|
||||||
@Input() valueLabel = 'Value';
|
|
||||||
@Input() keyPlaceholder = '';
|
|
||||||
@Input() valuePlaceholder = '';
|
|
||||||
@Input() addLabel = '+ Add';
|
|
||||||
/** Path parameters come from the path itself; the names cannot be edited or removed. */
|
|
||||||
@Input() fixedKeys = false;
|
|
||||||
@Input() emptyText = 'None.';
|
|
||||||
|
|
||||||
add(): void {
|
|
||||||
this.rows.push({ key: '', value: '', description: '', enabled: true, secret: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(i: number): void {
|
|
||||||
this.rows.splice(i, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<!--
|
|
||||||
Copyright since 2025 Mifos Initiative
|
|
||||||
|
|
||||||
This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
@if (log) {
|
|
||||||
<div class="cic-modal-backdrop" (click)="closed.emit()">
|
|
||||||
<div class="cic-modal" style="width: min(1100px, 100%)" (click)="$event.stopPropagation()">
|
|
||||||
<h3>
|
|
||||||
{{ log.vendorName }} · {{ log.apiName }}
|
|
||||||
<span class="cic-badge" [class.brand]="log.direction === 'IN'" [class.ok]="log.direction === 'OUT'">{{
|
|
||||||
log.direction
|
|
||||||
}}</span>
|
|
||||||
<span class="cic-badge" [class.good]="log.result === 'SUCCESS'" [class.bad]="log.result === 'FAILED'">{{
|
|
||||||
log.result
|
|
||||||
}}</span>
|
|
||||||
</h3>
|
|
||||||
<dl class="cic-kv" style="grid-template-columns: 120px minmax(0, 1fr); margin-bottom: 12px">
|
|
||||||
<dt>Reference</dt>
|
|
||||||
<dd class="cic-mono">{{ log.reference }}</dd>
|
|
||||||
<dt>Call</dt>
|
|
||||||
<dd class="cic-mono">{{ log.method }} {{ log.url }}</dd>
|
|
||||||
<dt>Result</dt>
|
|
||||||
<dd>HTTP {{ log.httpStatus }} in {{ log.durationMs }} ms · {{ log.time | slice: 0 : 16 }}</dd>
|
|
||||||
@if (log.error) {
|
|
||||||
<dt>Error</dt>
|
|
||||||
<dd class="tone-bad">{{ log.error }}</dd>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
<div class="cic-split">
|
|
||||||
<div>
|
|
||||||
<h4 style="margin: 0 0 6px; font-size: 13px">
|
|
||||||
{{ log.direction === 'OUT' ? 'Request headers' : 'Headers received / sent' }}
|
|
||||||
<span class="muted" style="font-weight: 400">(credentials masked)</span>
|
|
||||||
</h4>
|
|
||||||
<pre class="cic-code" style="max-height: 160px">{{ log.requestHeaders }}</pre>
|
|
||||||
<h4 style="margin: 12px 0 6px; font-size: 13px">Request payload</h4>
|
|
||||||
<pre class="cic-code">{{ log.request || '(empty)' }}</pre>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 style="margin: 0 0 6px; font-size: 13px">Response headers</h4>
|
|
||||||
<pre class="cic-code" style="max-height: 160px">{{ log.responseHeaders }}</pre>
|
|
||||||
<h4 style="margin: 12px 0 6px; font-size: 13px">Response body</h4>
|
|
||||||
<pre class="cic-code">{{ log.response || '(empty)' }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="foot">
|
|
||||||
<button class="cic-btn" (click)="closed.emit()">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright since 2025 Mifos Initiative
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
|
||||||
|
|
||||||
import { ApiLog } from '../api-management.service';
|
|
||||||
|
|
||||||
/** One API call: what was sent and what came back. */
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-api-log-detail',
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule],
|
|
||||||
templateUrl: './api-log-detail.component.html',
|
|
||||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class ApiLogDetailComponent {
|
|
||||||
@Input() log: ApiLog | null = null;
|
|
||||||
@Output() closed = new EventEmitter<void>();
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<!--
|
|
||||||
Copyright since 2025 Mifos Initiative
|
|
||||||
|
|
||||||
This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="cic-page">
|
|
||||||
<div class="cic-head">
|
|
||||||
<div>
|
|
||||||
<h1>API Call Logs</h1>
|
|
||||||
<p>Every API call the CBS made (OUT) or received (IN), across all vendors, with its request and response.</p>
|
|
||||||
</div>
|
|
||||||
<div class="cic-actions">
|
|
||||||
<a class="cic-btn" routerLink="/api-management/vendors">← All vendors</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cic-card">
|
|
||||||
<div class="cic-grid cols-4" style="align-items: end">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Vendor</label>
|
|
||||||
<select [(ngModel)]="filter.vendorId">
|
|
||||||
<option value="">All vendors</option>
|
|
||||||
@for (v of vendors; track v.id) {
|
|
||||||
<option [value]="v.id">{{ v.name }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Direction</label>
|
|
||||||
<select [(ngModel)]="filter.direction">
|
|
||||||
<option value="">IN and OUT</option>
|
|
||||||
<option value="IN">IN — received</option>
|
|
||||||
<option value="OUT">OUT — sent</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Result</label>
|
|
||||||
<select [(ngModel)]="filter.result">
|
|
||||||
<option value="">All</option>
|
|
||||||
<option value="SUCCESS">Success</option>
|
|
||||||
<option value="FAILED">Failed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Search</label>
|
|
||||||
<input [(ngModel)]="filter.search" (keyup.enter)="apply()" placeholder="Reference, API, path" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-actions" style="margin-top: 12px; justify-content: flex-end">
|
|
||||||
<button class="cic-btn" (click)="reset()">Reset</button>
|
|
||||||
<button class="cic-btn primary" (click)="apply()">Apply</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>
|
|
||||||
{{ total }} call(s)
|
|
||||||
<span class="cic-card-sub">
|
|
||||||
<span class="tone-good">{{ success }} success</span> ·
|
|
||||||
<span [class.tone-bad]="failed > 0">{{ failed }} failed</span>
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
@if (loading && !items.length) {
|
|
||||||
<div class="cic-empty">Loading…</div>
|
|
||||||
} @else if (!items.length) {
|
|
||||||
<div class="cic-empty">No call matches the filter.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>Vendor</th>
|
|
||||||
<th>API</th>
|
|
||||||
<th>Direction</th>
|
|
||||||
<th>Call</th>
|
|
||||||
<th>HTTP</th>
|
|
||||||
<th class="num">ms</th>
|
|
||||||
<th>Result</th>
|
|
||||||
<th>Reference</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (l of items; track l.id) {
|
|
||||||
<tr class="clickable" (click)="openLog = l">
|
|
||||||
<td>{{ l.time | slice: 0 : 16 }}</td>
|
|
||||||
<td>{{ l.vendorName }}</td>
|
|
||||||
<td>{{ l.apiName }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class.brand]="l.direction === 'IN'" [class.ok]="l.direction === 'OUT'">{{
|
|
||||||
l.direction
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px">{{ l.method }} {{ l.path }}</td>
|
|
||||||
<td>{{ l.httpStatus }}</td>
|
|
||||||
<td class="num">{{ l.durationMs }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class.good]="l.result === 'SUCCESS'" [class.bad]="l.result === 'FAILED'">{{
|
|
||||||
l.result
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px">{{ l.reference }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="cic-pager">
|
|
||||||
<span>{{ offset + 1 }}–{{ offset + items.length }} of {{ total }}</span>
|
|
||||||
<span class="cic-actions">
|
|
||||||
<button class="cic-btn sm" (click)="page(-1)" [disabled]="offset === 0">Previous</button>
|
|
||||||
<button class="cic-btn sm" (click)="page(1)" [disabled]="offset + limit >= total">Next</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<mifosx-api-log-detail [log]="openLog" (closed)="openLog = null"></mifosx-api-log-detail>
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright since 2025 Mifos Initiative
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { ApiLogDetailComponent } from '../api-log-detail/api-log-detail.component';
|
|
||||||
import { ApiLog, ApiManagementService, ApiVendor } from '../api-management.service';
|
|
||||||
|
|
||||||
/** Every API call, IN and OUT, across all vendors, with its request and response. */
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-api-logs',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
RouterModule,
|
|
||||||
ApiLogDetailComponent
|
|
||||||
],
|
|
||||||
templateUrl: './api-logs.component.html',
|
|
||||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class ApiLogsComponent implements OnInit {
|
|
||||||
private service = inject(ApiManagementService);
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
|
|
||||||
vendors: ApiVendor[] = [];
|
|
||||||
filter = { vendorId: '', direction: '', result: '', search: '' };
|
|
||||||
items: ApiLog[] = [];
|
|
||||||
total = 0;
|
|
||||||
success = 0;
|
|
||||||
failed = 0;
|
|
||||||
offset = 0;
|
|
||||||
readonly limit = 15;
|
|
||||||
loading = false;
|
|
||||||
openLog: ApiLog | null = null;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
const vendorId = this.route.snapshot.queryParamMap.get('vendorId');
|
|
||||||
if (vendorId) this.filter.vendorId = vendorId;
|
|
||||||
this.service.vendors().subscribe((v) => (this.vendors = v));
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.service.logs({ ...this.filter, offset: this.offset, limit: this.limit }).subscribe((r) => {
|
|
||||||
this.items = r.items;
|
|
||||||
this.total = r.total;
|
|
||||||
this.success = r.success;
|
|
||||||
this.failed = r.failed;
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
apply(): void {
|
|
||||||
this.offset = 0;
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.filter = { vendorId: '', direction: '', result: '', search: '' };
|
|
||||||
this.apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
page(delta: number): void {
|
|
||||||
this.offset = Math.max(0, this.offset + delta * this.limit);
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright since 2025 Mifos Initiative
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NgModule } from '@angular/core';
|
|
||||||
import { Routes, RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { Route } from '../core/route/route.service';
|
|
||||||
import { ApiVendorsComponent } from './api-vendors/api-vendors.component';
|
|
||||||
import { ApiVendorSetupComponent } from './api-vendor-setup/api-vendor-setup.component';
|
|
||||||
import { ApiLogsComponent } from './api-logs/api-logs.component';
|
|
||||||
|
|
||||||
const routes: Routes = [
|
|
||||||
Route.withShell([
|
|
||||||
{
|
|
||||||
path: 'api-management',
|
|
||||||
data: { title: 'API Management', breadcrumb: 'API Management' },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
redirectTo: 'vendors',
|
|
||||||
pathMatch: 'full'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'vendors',
|
|
||||||
data: { breadcrumb: 'Vendors' },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
component: ApiVendorsComponent,
|
|
||||||
data: { title: 'API Vendors & Setup' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ':id',
|
|
||||||
component: ApiVendorSetupComponent,
|
|
||||||
data: { title: 'Vendor Setup', breadcrumb: 'Setup' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'logs',
|
|
||||||
component: ApiLogsComponent,
|
|
||||||
data: { title: 'API Call Logs', breadcrumb: 'Call Logs' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
])
|
|
||||||
];
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [RouterModule.forChild(routes)],
|
|
||||||
exports: [RouterModule]
|
|
||||||
})
|
|
||||||
export class ApiManagementRoutingModule {}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright since 2025 Mifos Initiative
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NgModule } from '@angular/core';
|
|
||||||
|
|
||||||
import { SharedModule } from '../shared/shared.module';
|
|
||||||
import { ApiManagementRoutingModule } from './api-management-routing.module';
|
|
||||||
import { ApiVendorsComponent } from './api-vendors/api-vendors.component';
|
|
||||||
import { ApiVendorSetupComponent } from './api-vendor-setup/api-vendor-setup.component';
|
|
||||||
import { ApiLogsComponent } from './api-logs/api-logs.component';
|
|
||||||
import { ApiLogDetailComponent } from './api-log-detail/api-log-detail.component';
|
|
||||||
|
|
||||||
/** API Management: connect any outside API service (CIC, SMS, WhatsApp ...) - vendor, work, API set, IN / OUT. */
|
|
||||||
@NgModule({
|
|
||||||
imports: [
|
|
||||||
SharedModule,
|
|
||||||
ApiManagementRoutingModule,
|
|
||||||
ApiVendorsComponent,
|
|
||||||
ApiVendorSetupComponent,
|
|
||||||
ApiLogsComponent,
|
|
||||||
ApiLogDetailComponent
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ApiManagementModule {}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,777 +0,0 @@
|
|||||||
<!--
|
|
||||||
Copyright since 2025 Mifos Initiative
|
|
||||||
|
|
||||||
This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="cic-page">
|
|
||||||
<div class="cic-head">
|
|
||||||
<div>
|
|
||||||
<h1>{{ vendor?.name || 'Vendor setup' }}</h1>
|
|
||||||
<p>{{ vendor?.work }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="cic-actions">
|
|
||||||
@if (vendor) {
|
|
||||||
<span class="cic-badge" [class.good]="vendor.active">{{ vendor.active ? 'Active' : 'Switched off' }}</span>
|
|
||||||
<span
|
|
||||||
class="cic-badge"
|
|
||||||
[class.warn]="vendor.environment === 'SIMULATOR'"
|
|
||||||
[class.good]="vendor.environment === 'PRODUCTION'"
|
|
||||||
>{{ vendor.environment }}</span
|
|
||||||
>
|
|
||||||
}
|
|
||||||
<a class="cic-btn" routerLink="/api-management/vendors">← All vendors</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (error && !editing && !importOpen) {
|
|
||||||
<div class="cic-alert error">{{ error }}</div>
|
|
||||||
}
|
|
||||||
@if (info && !editing) {
|
|
||||||
<div class="cic-alert success">{{ info }}</div>
|
|
||||||
}
|
|
||||||
@if (loading && !vendor) {
|
|
||||||
<div class="cic-card cic-empty">Loading…</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (vendor) {
|
|
||||||
@if (vendor.configNote) {
|
|
||||||
<div class="cic-alert info">{{ vendor.configNote }}</div>
|
|
||||||
}
|
|
||||||
@if (vendor.links.length) {
|
|
||||||
<div class="cic-alert info">
|
|
||||||
{{ vendor.code }} also has its own screens:
|
|
||||||
@for (l of vendor.links; track l.route) {
|
|
||||||
<a class="cic-btn sm" style="margin-left: 6px" [routerLink]="l.route">{{ l.label }}</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cic-tabs">
|
|
||||||
<button [class.active]="tab === 'connection'" (click)="tab = 'connection'">1 · Connection</button>
|
|
||||||
<button [class.active]="tab === 'apis'" (click)="tab = 'apis'">2 · API set ({{ vendor.apis.length }})</button>
|
|
||||||
<button [class.active]="tab === 'test'" (click)="tab = 'test'">3 · Test</button>
|
|
||||||
<button [class.active]="tab === 'calls'" (click)="tab = 'calls'; loadCalls()">Recent calls</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CONNECTION -->
|
|
||||||
@if (tab === 'connection') {
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>Vendor and connection</h2>
|
|
||||||
<div class="cic-grid cols-2">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Vendor name</label>
|
|
||||||
<input [(ngModel)]="form.name" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Environment</label>
|
|
||||||
<select [(ngModel)]="form.environment">
|
|
||||||
<option value="SIMULATOR">SIMULATOR — no network call</option>
|
|
||||||
<option value="UAT">UAT — vendor's test environment</option>
|
|
||||||
<option value="PRODUCTION">PRODUCTION — live</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field" style="grid-column: 1 / -1">
|
|
||||||
<label>Work — what this vendor is used for</label>
|
|
||||||
<input [(ngModel)]="form.work" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field" style="grid-column: 1 / -1">
|
|
||||||
<label>Base URL</label>
|
|
||||||
<input class="cic-mono" [(ngModel)]="form.baseUrl" placeholder="https://api.vendor.example/v1" />
|
|
||||||
<span class="hint">Every OUT / pull API path is added to this. PRODUCTION needs https://.</span>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Authentication</label>
|
|
||||||
<select [(ngModel)]="form.authType">
|
|
||||||
@for (a of authTypes; track a.key) {
|
|
||||||
<option [value]="a.key">{{ a.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
@if (form.authType === 'BASIC') {
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>User id</label>
|
|
||||||
<input [(ngModel)]="form.username" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (form.authType === 'API_KEY') {
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>API key header name</label>
|
|
||||||
<input class="cic-mono" [(ngModel)]="form.apiKeyHeader" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (needsSecret()) {
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>{{ secretLabel() }}</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
[(ngModel)]="form.secret"
|
|
||||||
autocomplete="new-password"
|
|
||||||
[placeholder]="vendor.hasSecret ? '•••••• stored — leave blank to keep' : 'Enter to store (encrypted)'"
|
|
||||||
/>
|
|
||||||
@if (vendor.hasSecret) {
|
|
||||||
<label style="display: flex; gap: 6px; align-items: center; font-weight: 500; font-size: 12px">
|
|
||||||
<input type="checkbox" [(ngModel)]="form.clearSecret" style="width: auto" /> Remove the stored
|
|
||||||
credential
|
|
||||||
</label>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Sender / member id</label>
|
|
||||||
<input [(ngModel)]="form.senderId" />
|
|
||||||
<span class="hint"
|
|
||||||
>The bank's id at this vendor (SMS sender id, WhatsApp number id, CIC member code …).</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Timeout (ms)</label>
|
|
||||||
<input type="number" min="1000" max="300000" [(ngModel)]="form.timeoutMs" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Retries on failure</label>
|
|
||||||
<input type="number" min="0" max="5" [(ngModel)]="form.retries" />
|
|
||||||
</div>
|
|
||||||
<label style="display: flex; gap: 8px; align-items: center; font-size: 13px">
|
|
||||||
<input type="checkbox" [(ngModel)]="form.active" /> Vendor active
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 style="margin: 18px 0 6px; font-size: 13px">
|
|
||||||
Default headers
|
|
||||||
<span class="muted" style="font-weight: 400"
|
|
||||||
>— sent on every API of this vendor (an API can override one)</span
|
|
||||||
>
|
|
||||||
</h4>
|
|
||||||
<mifosx-api-key-value-editor
|
|
||||||
[rows]="form.defaultHeaders"
|
|
||||||
keyLabel="Header"
|
|
||||||
keyPlaceholder="Accept"
|
|
||||||
addLabel="+ Add header"
|
|
||||||
emptyText="No default header."
|
|
||||||
></mifosx-api-key-value-editor>
|
|
||||||
|
|
||||||
<div class="cic-actions" style="margin-top: 16px">
|
|
||||||
<button class="cic-btn primary" (click)="saveConnection()" [disabled]="saving">
|
|
||||||
{{ saving ? 'Saving…' : 'Save connection' }}
|
|
||||||
</button>
|
|
||||||
<button class="cic-btn" (click)="tab = 'apis'">Next: API set →</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- API SET -->
|
|
||||||
@if (tab === 'apis') {
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>
|
|
||||||
API set
|
|
||||||
<span class="cic-actions">
|
|
||||||
<button class="cic-btn sm" (click)="newApi('OUT')">+ OUT API (CBS calls vendor)</button>
|
|
||||||
<button class="cic-btn sm" (click)="newApi('IN')">+ IN API (data comes into CBS)</button>
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
@if (!vendor.apis.length) {
|
|
||||||
<div class="cic-empty">
|
|
||||||
No API yet. Add one, then fill it in by hand or read it from a JSON / cURL / Postman file.
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>API</th>
|
|
||||||
<th>Direction</th>
|
|
||||||
<th>Call</th>
|
|
||||||
<th>Used for</th>
|
|
||||||
<th>Headers / query</th>
|
|
||||||
<th>Mapping</th>
|
|
||||||
<th>Active</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (a of vendor.apis; track a.id) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<b>{{ a.name }}</b>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class.brand]="a.direction === 'IN'" [class.ok]="a.direction === 'OUT'">{{
|
|
||||||
a.direction
|
|
||||||
}}</span>
|
|
||||||
@if (a.mode) {
|
|
||||||
<span class="muted" style="font-size: 11px"> {{ a.mode === 'PULL' ? 'pull' : 'push' }}</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px">{{ a.method }} {{ a.path }}</td>
|
|
||||||
<td style="max-width: 260px; white-space: normal">{{ a.usedFor }}</td>
|
|
||||||
<td>{{ a.headers.length }} / {{ a.queryParams.length }}</td>
|
|
||||||
<td>{{ a.requestRows.length }} / {{ a.responseRows.length }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class.good]="a.active">{{ a.active ? 'Yes' : 'Off' }}</span>
|
|
||||||
</td>
|
|
||||||
<td style="white-space: nowrap">
|
|
||||||
<button class="cic-btn sm" (click)="editApi(a)">Edit</button>
|
|
||||||
<button class="cic-btn sm danger" (click)="deleteApi(a)">✕</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- TEST -->
|
|
||||||
@if (tab === 'test') {
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>
|
|
||||||
Test an API
|
|
||||||
<span class="cic-card-sub">simulated — nothing is sent to the vendor; the result is added to the logs</span>
|
|
||||||
</h2>
|
|
||||||
@if (!vendor.apis.length) {
|
|
||||||
<div class="cic-empty">Add an API first.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-field" style="max-width: 420px">
|
|
||||||
<label>API</label>
|
|
||||||
<select [(ngModel)]="testApiId" (ngModelChange)="fillTestBody()">
|
|
||||||
@for (a of vendor.apis; track a.id) {
|
|
||||||
<option [ngValue]="a.id">{{ a.direction }} · {{ a.name }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
@if (testPreview; as p) {
|
|
||||||
<h4 style="margin: 14px 0 6px; font-size: 13px">The request</h4>
|
|
||||||
<pre class="cic-code" style="max-height: 200px">{{ p.method }} {{ p.url }}
|
|
||||||
@for (h of p.headers; track h.key) {
|
|
||||||
{{ h.key }}: {{ h.value }}
|
|
||||||
}</pre>
|
|
||||||
}
|
|
||||||
<div class="cic-field" style="margin-top: 12px">
|
|
||||||
<label>Request body</label>
|
|
||||||
<textarea class="cic-mono" rows="9" [(ngModel)]="testBody"></textarea>
|
|
||||||
</div>
|
|
||||||
<button class="cic-btn primary" style="margin-top: 12px" (click)="runTest()" [disabled]="testing">
|
|
||||||
{{ testing ? 'Sending…' : 'Send test' }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (testResult) {
|
|
||||||
<div class="cic-alert success">
|
|
||||||
{{ testResult.reference }} — HTTP {{ testResult.httpStatus }} in {{ testResult.durationMs }} ms
|
|
||||||
</div>
|
|
||||||
<div class="cic-split">
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>Response headers</h2>
|
|
||||||
<pre class="cic-code">{{ testResult.responseHeaders }}</pre>
|
|
||||||
</div>
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>Response body</h2>
|
|
||||||
<pre class="cic-code">{{ testResult.response || '(empty)' }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- RECENT CALLS -->
|
|
||||||
@if (tab === 'calls') {
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>
|
|
||||||
Recent calls
|
|
||||||
<a class="cic-btn sm" routerLink="/api-management/logs" [queryParams]="{ vendorId: vendor.id }">All logs</a>
|
|
||||||
</h2>
|
|
||||||
@if (!calls.length) {
|
|
||||||
<div class="cic-empty">No call yet.</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Time</th>
|
|
||||||
<th>API</th>
|
|
||||||
<th>Direction</th>
|
|
||||||
<th>HTTP</th>
|
|
||||||
<th class="num">ms</th>
|
|
||||||
<th>Result</th>
|
|
||||||
<th>Reference</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (l of calls; track l.id) {
|
|
||||||
<tr class="clickable" (click)="openLog = l">
|
|
||||||
<td>{{ l.time | slice: 0 : 16 }}</td>
|
|
||||||
<td>{{ l.apiName }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-badge" [class.brand]="l.direction === 'IN'" [class.ok]="l.direction === 'OUT'">{{
|
|
||||||
l.direction
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td>{{ l.httpStatus }}</td>
|
|
||||||
<td class="num">{{ l.durationMs }}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="cic-badge"
|
|
||||||
[class.good]="l.result === 'SUCCESS'"
|
|
||||||
[class.bad]="l.result === 'FAILED'"
|
|
||||||
>{{ l.result }}</span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px">{{ l.reference }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API editor -->
|
|
||||||
@if (editing) {
|
|
||||||
<div class="cic-modal-backdrop" (click)="editing = null">
|
|
||||||
<div class="cic-modal" style="width: min(1100px, 100%)" (click)="$event.stopPropagation()">
|
|
||||||
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: flex-start">
|
|
||||||
<h3 style="margin: 0">
|
|
||||||
{{ isNewApi ? 'New' : 'Edit' }} API
|
|
||||||
<span class="cic-badge" [class.brand]="editing.direction === 'IN'" [class.ok]="editing.direction === 'OUT'">{{
|
|
||||||
editing.direction
|
|
||||||
}}</span>
|
|
||||||
@if (editing.mode) {
|
|
||||||
<span class="muted" style="font-size: 12px; font-weight: 500">
|
|
||||||
{{ editing.mode === 'PULL' ? 'pull' : 'push' }}</span
|
|
||||||
>
|
|
||||||
}
|
|
||||||
</h3>
|
|
||||||
<button class="cic-btn sm primary" (click)="openImport()">⬆ Import from JSON / cURL / Postman</button>
|
|
||||||
</div>
|
|
||||||
<p class="muted" style="font-size: 12px; margin: 6px 0 10px">
|
|
||||||
@if (editing.direction === 'OUT') {
|
|
||||||
OUT — the CBS calls the vendor: fill the request it sends and the response it expects.
|
|
||||||
} @else if (editing.mode === 'PULL') {
|
|
||||||
IN (pull) — the CBS asks the vendor for data on a schedule: fill the request it sends and the response it
|
|
||||||
reads.
|
|
||||||
} @else {
|
|
||||||
IN (push) — the vendor calls a CBS path: fill what the vendor sends us and what we answer.
|
|
||||||
}
|
|
||||||
Type everything in, or read it from a file with the import button — then check each tab.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if (info) {
|
|
||||||
<div class="cic-alert success">{{ info }}</div>
|
|
||||||
}
|
|
||||||
@if (error) {
|
|
||||||
<div class="cic-alert error">{{ error }}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cic-tabs">
|
|
||||||
<button [class.active]="editorTab === 'basics'" (click)="editorTab = 'basics'">1 · Basics</button>
|
|
||||||
<button [class.active]="editorTab === 'request'" (click)="editorTab = 'request'">
|
|
||||||
2 · {{ outbound ? 'Request' : 'What the vendor sends' }}
|
|
||||||
</button>
|
|
||||||
<button [class.active]="editorTab === 'response'" (click)="editorTab = 'response'">
|
|
||||||
3 · {{ outbound ? 'Response' : 'Our answer' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- BASICS -->
|
|
||||||
@if (editorTab === 'basics') {
|
|
||||||
<div class="cic-grid cols-4">
|
|
||||||
<div class="cic-field" style="grid-column: span 2">
|
|
||||||
<label>API name</label>
|
|
||||||
<input [(ngModel)]="editing.name" placeholder="e.g. Send SMS" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Direction</label>
|
|
||||||
<select [(ngModel)]="editing.direction" (ngModelChange)="directionChanged()">
|
|
||||||
<option value="OUT">OUT — CBS calls vendor</option>
|
|
||||||
<option value="IN">IN — data comes into CBS</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
@if (editing.direction === 'IN') {
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>How data arrives</label>
|
|
||||||
<select [(ngModel)]="editing.mode">
|
|
||||||
<option value="PULL">PULL — CBS asks on a schedule</option>
|
|
||||||
<option value="PUSH">PUSH — vendor calls a CBS path</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div></div>
|
|
||||||
}
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>HTTP method</label>
|
|
||||||
<select [(ngModel)]="editing.method">
|
|
||||||
@for (m of methods; track m) {
|
|
||||||
<option [value]="m">{{ m }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field" style="grid-column: span 3">
|
|
||||||
<label>{{ outbound ? "Vendor's API path (after the base URL)" : 'CBS path the vendor calls' }}</label>
|
|
||||||
<input
|
|
||||||
class="cic-mono"
|
|
||||||
[(ngModel)]="editing.path"
|
|
||||||
(ngModelChange)="pathChanged()"
|
|
||||||
placeholder="/messages/send"
|
|
||||||
/>
|
|
||||||
<span class="hint"
|
|
||||||
>Use {{ '{' }}name{{ '}' }} for a value that changes, e.g. /v21.0/{{ '{' }}senderId{{
|
|
||||||
'}'
|
|
||||||
}}/messages.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field" style="grid-column: span 3">
|
|
||||||
<label>When is it used</label>
|
|
||||||
<input [(ngModel)]="editing.usedFor" placeholder="e.g. OTP and transaction alerts" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Timeout for this API (ms)</label>
|
|
||||||
<input type="number" min="1000" max="300000" [(ngModel)]="editing.timeoutMs" placeholder="vendor's" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Retries for this API</label>
|
|
||||||
<input type="number" min="0" max="5" [(ngModel)]="editing.retries" placeholder="vendor's" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label style="display: flex; gap: 8px; align-items: center; font-size: 13px; margin: 12px 0">
|
|
||||||
<input type="checkbox" [(ngModel)]="editing.active" /> API active
|
|
||||||
</label>
|
|
||||||
|
|
||||||
@if (preview(editing); as p) {
|
|
||||||
<h4 style="margin: 14px 0 6px; font-size: 13px">
|
|
||||||
{{ outbound ? 'The call as it will go out' : 'The call the vendor will make to us' }}
|
|
||||||
<span class="muted" style="font-weight: 400"> (credentials masked)</span>
|
|
||||||
</h4>
|
|
||||||
<pre class="cic-code" style="max-height: 220px">{{ p.method }} {{ p.url }}
|
|
||||||
@for (h of p.headers; track h.key) {
|
|
||||||
{{ h.key }}: {{ h.value }}
|
|
||||||
}
|
|
||||||
{{ p.body }}</pre>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- REQUEST -->
|
|
||||||
@if (editorTab === 'request') {
|
|
||||||
<h4 style="margin: 4px 0 6px; font-size: 13px">Path parameters</h4>
|
|
||||||
<mifosx-api-key-value-editor
|
|
||||||
[rows]="editing.pathParams"
|
|
||||||
keyLabel="Parameter"
|
|
||||||
valueLabel="Value (a CBS placeholder or fixed value)"
|
|
||||||
[fixedKeys]="true"
|
|
||||||
emptyText="The path has no {name} placeholder."
|
|
||||||
></mifosx-api-key-value-editor>
|
|
||||||
|
|
||||||
<h4 style="margin: 16px 0 6px; font-size: 13px">Query parameters</h4>
|
|
||||||
<mifosx-api-key-value-editor
|
|
||||||
[rows]="editing.queryParams"
|
|
||||||
keyLabel="Parameter"
|
|
||||||
keyPlaceholder="version"
|
|
||||||
addLabel="+ Add query parameter"
|
|
||||||
emptyText="No query parameter."
|
|
||||||
></mifosx-api-key-value-editor>
|
|
||||||
|
|
||||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
|
||||||
Headers
|
|
||||||
<span class="muted" style="font-weight: 400"
|
|
||||||
>— this API's own; the vendor's default headers and its authentication are added automatically</span
|
|
||||||
>
|
|
||||||
</h4>
|
|
||||||
<mifosx-api-key-value-editor
|
|
||||||
[rows]="editing.headers"
|
|
||||||
keyLabel="Header"
|
|
||||||
keyPlaceholder="X-Request-Id"
|
|
||||||
valuePlaceholder="{{ '{{' }}request.reference{{ '}}' }}"
|
|
||||||
addLabel="+ Add header"
|
|
||||||
emptyText="No header of its own."
|
|
||||||
></mifosx-api-key-value-editor>
|
|
||||||
|
|
||||||
<div class="cic-grid cols-4" style="margin-top: 16px">
|
|
||||||
<div class="cic-field" style="grid-column: span 2">
|
|
||||||
<label>Content type</label>
|
|
||||||
<select [(ngModel)]="editing.contentType">
|
|
||||||
@for (c of contentTypes; track c.key) {
|
|
||||||
<option [value]="c.key">{{ c.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cic-field" style="margin-top: 12px">
|
|
||||||
<label>{{ outbound ? 'Request payload' : 'Payload the vendor sends us' }}</label>
|
|
||||||
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.requestBody"></textarea>
|
|
||||||
<span class="hint">
|
|
||||||
{{
|
|
||||||
outbound
|
|
||||||
? 'The constant parts of the payload; the mapping below puts CBS values into it.'
|
|
||||||
: 'A sample of what arrives; the mapping reads values out of it.'
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
|
||||||
{{ mappingTitle('request') }}
|
|
||||||
<span class="muted" style="font-weight: 400; font-size: 12px">
|
|
||||||
· {{ matchedCount('request') }} of {{ editing.requestRows.length }} rows found in the sample
|
|
||||||
{{ 'request' === 'request' ? 'payload' : 'body' }}
|
|
||||||
</span>
|
|
||||||
<button class="cic-btn sm" style="margin-left: 8px" (click)="generateRows('request')">
|
|
||||||
Build rows from the payload
|
|
||||||
</button>
|
|
||||||
</h4>
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{ fromLabel('request') }}</th>
|
|
||||||
<th>{{ toLabel('request') }}</th>
|
|
||||||
<th>Transform</th>
|
|
||||||
<th>Fixed / default value</th>
|
|
||||||
<th>In the sample payload</th>
|
|
||||||
<th>Required</th>
|
|
||||||
<th style="width: 40px"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@let requestMatch = sampleMatches('request');
|
|
||||||
@for (r of editing.requestRows; track $index; let i = $index) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
@if (fromIsCbs('request')) {
|
|
||||||
<select [(ngModel)]="r.from">
|
|
||||||
<option value="">— fixed value (set it on the right) —</option>
|
|
||||||
@for (f of cbsFields; track f.key) {
|
|
||||||
<option [value]="f.key">{{ f.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
} @else {
|
|
||||||
<input class="cic-mono" [(ngModel)]="r.from" />
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td><input class="cic-mono" [(ngModel)]="r.to" /></td>
|
|
||||||
<td>
|
|
||||||
<select [(ngModel)]="r.transform">
|
|
||||||
@for (t of transforms; track t.key) {
|
|
||||||
<option [value]="t.key">{{ t.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td><input [(ngModel)]="r.defaultValue" /></td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px; white-space: nowrap">
|
|
||||||
@if (requestMatch[i].found) {
|
|
||||||
<span class="tone-good">✓ {{ requestMatch[i].value }}</span>
|
|
||||||
} @else if (r.defaultValue) {
|
|
||||||
<span class="muted">fixed: {{ r.defaultValue }}</span>
|
|
||||||
} @else {
|
|
||||||
<span class="tone-bad">✗ {{ requestMatch[i].value || 'no path' }}</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="r.mandatory" style="width: auto" />
|
|
||||||
</td>
|
|
||||||
<td><button class="cic-btn sm danger" (click)="removeRow('request', i)">✕</button></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<button class="cic-btn sm" style="margin-top: 8px" (click)="addRow('request')">+ Add field</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- RESPONSE -->
|
|
||||||
@if (editorTab === 'response') {
|
|
||||||
<div class="cic-grid cols-4">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Success status code(s)</label>
|
|
||||||
<input class="cic-mono" [(ngModel)]="editing.successCodes" placeholder="200, 202" />
|
|
||||||
<span class="hint">{{
|
|
||||||
outbound ? 'Codes the vendor answers with when it worked.' : 'Codes the CBS answers with.'
|
|
||||||
}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
|
||||||
{{ outbound ? 'Response headers' : 'Headers of our answer' }}
|
|
||||||
</h4>
|
|
||||||
<mifosx-api-key-value-editor
|
|
||||||
[rows]="editing.responseHeaders"
|
|
||||||
keyLabel="Header"
|
|
||||||
keyPlaceholder="Content-Type"
|
|
||||||
addLabel="+ Add header"
|
|
||||||
emptyText="No response header noted."
|
|
||||||
></mifosx-api-key-value-editor>
|
|
||||||
|
|
||||||
<div class="cic-grid cols-2" style="margin-top: 16px">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>{{ outbound ? 'Success response body' : 'Our answer body' }}</label>
|
|
||||||
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.responseBody"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Error response body</label>
|
|
||||||
<textarea class="cic-mono" rows="10" [(ngModel)]="editing.errorBody"></textarea>
|
|
||||||
<span class="hint"
|
|
||||||
>What comes back when it fails (wrong key, rate limit …) — shown in the logs for a failed call.</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h4 style="margin: 16px 0 6px; font-size: 13px">
|
|
||||||
{{ mappingTitle('response') }}
|
|
||||||
<span class="muted" style="font-weight: 400; font-size: 12px">
|
|
||||||
· {{ matchedCount('response') }} of {{ editing.responseRows.length }} rows found in the sample
|
|
||||||
{{ 'response' === 'request' ? 'payload' : 'body' }}
|
|
||||||
</span>
|
|
||||||
<button class="cic-btn sm" style="margin-left: 8px" (click)="generateRows('response')">
|
|
||||||
Build rows from the body
|
|
||||||
</button>
|
|
||||||
</h4>
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{ fromLabel('response') }}</th>
|
|
||||||
<th>{{ toLabel('response') }}</th>
|
|
||||||
<th>Transform</th>
|
|
||||||
<th>Fixed / default value</th>
|
|
||||||
<th>In the sample payload</th>
|
|
||||||
<th>Required</th>
|
|
||||||
<th style="width: 40px"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@let responseMatch = sampleMatches('response');
|
|
||||||
@for (r of editing.responseRows; track $index; let i = $index) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
@if (fromIsCbs('response')) {
|
|
||||||
<select [(ngModel)]="r.from">
|
|
||||||
<option value="">— fixed value (set it on the right) —</option>
|
|
||||||
@for (f of cbsFields; track f.key) {
|
|
||||||
<option [value]="f.key">{{ f.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
} @else {
|
|
||||||
<input class="cic-mono" [(ngModel)]="r.from" />
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td><input class="cic-mono" [(ngModel)]="r.to" /></td>
|
|
||||||
<td>
|
|
||||||
<select [(ngModel)]="r.transform">
|
|
||||||
@for (t of transforms; track t.key) {
|
|
||||||
<option [value]="t.key">{{ t.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td><input [(ngModel)]="r.defaultValue" /></td>
|
|
||||||
<td class="cic-mono" style="font-size: 12px; white-space: nowrap">
|
|
||||||
@if (responseMatch[i].found) {
|
|
||||||
<span class="tone-good">✓ {{ responseMatch[i].value }}</span>
|
|
||||||
} @else if (r.defaultValue) {
|
|
||||||
<span class="muted">fixed: {{ r.defaultValue }}</span>
|
|
||||||
} @else {
|
|
||||||
<span class="tone-bad">✗ {{ responseMatch[i].value || 'no path' }}</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td style="text-align: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="r.mandatory" style="width: auto" />
|
|
||||||
</td>
|
|
||||||
<td><button class="cic-btn sm danger" (click)="removeRow('response', i)">✕</button></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<button class="cic-btn sm" style="margin-top: 8px" (click)="addRow('response')">+ Add field</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="foot">
|
|
||||||
<button class="cic-btn" (click)="editing = null">Cancel</button>
|
|
||||||
<button class="cic-btn primary" (click)="saveApi()" [disabled]="savingApi">
|
|
||||||
{{ savingApi ? 'Saving…' : 'Save API' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Import from JSON / cURL / Postman / HAR -->
|
|
||||||
@if (importOpen && editing) {
|
|
||||||
<div class="cic-modal-backdrop" style="z-index: 1100" (click)="importOpen = false">
|
|
||||||
<div class="cic-modal" style="width: min(1000px, 100%)" (click)="$event.stopPropagation()">
|
|
||||||
<h3>Import from JSON / cURL / Postman</h3>
|
|
||||||
<p class="muted" style="font-size: 12px; margin: 0 0 10px">
|
|
||||||
Paste it or choose a file —
|
|
||||||
{{ outbound ? 'the request and the response' : 'what the vendor sends us and what we answer' }}. Any of these
|
|
||||||
works: a <b>cURL</b> command, a <b>Postman</b> collection or request, a <b>HAR</b> capture, an API definition
|
|
||||||
(<code>{{ '{' }} method, url, headers, query, body, status … {{ '}' }}</code
|
|
||||||
>), or just the <b>JSON payload</b>. The method, URL / path, query parameters, headers, content type, payload,
|
|
||||||
status code, response headers, bodies and a first CBS mapping are filled in for you. Passwords, keys and tokens
|
|
||||||
are never kept.
|
|
||||||
</p>
|
|
||||||
@if (importError) {
|
|
||||||
<div class="cic-alert error">{{ importError }}</div>
|
|
||||||
}
|
|
||||||
<div class="cic-grid cols-2">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label
|
|
||||||
>{{ outbound ? 'Request' : 'What the vendor sends us' }} — cURL, Postman, HAR, definition or payload</label
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
class="cic-mono"
|
|
||||||
rows="12"
|
|
||||||
[(ngModel)]="importRequestText"
|
|
||||||
placeholder="curl -X POST 'https://api.vendor.in/v1/send' -H 'Content-Type: application/json' -d '{ ... }'"
|
|
||||||
></textarea>
|
|
||||||
<div style="display: flex; gap: 8px; align-items: center">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".json,.txt,.curl,.har,.sh,application/json,text/plain"
|
|
||||||
(change)="onFile($event, 'request')"
|
|
||||||
/>
|
|
||||||
@if (importRequestFile) {
|
|
||||||
<span class="muted" style="font-size: 12px">{{ importRequestFile }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>{{ outbound ? 'Response' : 'Our answer' }} — JSON body, or with status and headers (optional)</label>
|
|
||||||
<textarea
|
|
||||||
class="cic-mono"
|
|
||||||
rows="12"
|
|
||||||
[(ngModel)]="importResponseText"
|
|
||||||
placeholder='{ "status": 200, "headers": { "Content-Type": "application/json" }, "body": { ... } }'
|
|
||||||
></textarea>
|
|
||||||
<div style="display: flex; gap: 8px; align-items: center">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".json,.txt,.har,application/json,text/plain"
|
|
||||||
(change)="onFile($event, 'response')"
|
|
||||||
/>
|
|
||||||
@if (importResponseFile) {
|
|
||||||
<span class="muted" style="font-size: 12px">{{ importResponseFile }}</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: 20px; flex-wrap: wrap; margin-top: 12px; font-size: 13px">
|
|
||||||
<label style="display: flex; gap: 6px; align-items: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="importReplace" /> Replace what is there now (headers, query, mapping rows)
|
|
||||||
— untick to add to it
|
|
||||||
</label>
|
|
||||||
<label style="display: flex; gap: 6px; align-items: center">
|
|
||||||
<input type="checkbox" [(ngModel)]="importGenerate" /> Build the CBS field mapping from the payloads
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="foot">
|
|
||||||
<button class="cic-btn" (click)="importOpen = false">Cancel</button>
|
|
||||||
<button class="cic-btn primary" (click)="applyImport()">Read and fill in</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<mifosx-api-log-detail [log]="openLog" (closed)="openLog = null"></mifosx-api-log-detail>
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright since 2025 Mifos Initiative
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { ApiKeyValueEditorComponent } from '../api-key-value-editor/api-key-value-editor.component';
|
|
||||||
import { ApiLogDetailComponent } from '../api-log-detail/api-log-detail.component';
|
|
||||||
import {
|
|
||||||
guessRequestRows,
|
|
||||||
guessResponseRows,
|
|
||||||
ImportedRequest,
|
|
||||||
ImportedResponse,
|
|
||||||
parseRequestText,
|
|
||||||
parseResponseText
|
|
||||||
} from '../api-import.util';
|
|
||||||
import {
|
|
||||||
API_AUTH_TYPES,
|
|
||||||
API_CBS_FIELDS,
|
|
||||||
API_CONTENT_TYPES,
|
|
||||||
API_METHODS,
|
|
||||||
API_TRANSFORMS,
|
|
||||||
ApiLog,
|
|
||||||
ApiManagementService,
|
|
||||||
ApiMappingRow,
|
|
||||||
ApiVendor,
|
|
||||||
blankApi,
|
|
||||||
buildRequestPreview,
|
|
||||||
callsVendor,
|
|
||||||
KeyValue,
|
|
||||||
pathParamNames,
|
|
||||||
RequestPreview,
|
|
||||||
VendorApi
|
|
||||||
} from '../api-management.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One vendor's setup: its connection (URL, authentication, default headers, credentials), its API set - each API IN or
|
|
||||||
* OUT with the full HTTP detail and the field mapping, typed in or read from a JSON / cURL / Postman / HAR file - a
|
|
||||||
* test call, and its recent calls.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-api-vendor-setup',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
RouterModule,
|
|
||||||
ApiLogDetailComponent,
|
|
||||||
ApiKeyValueEditorComponent
|
|
||||||
],
|
|
||||||
templateUrl: './api-vendor-setup.component.html',
|
|
||||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class ApiVendorSetupComponent implements OnInit {
|
|
||||||
private service = inject(ApiManagementService);
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
|
|
||||||
vendor: ApiVendor | null = null;
|
|
||||||
tab: 'connection' | 'apis' | 'test' | 'calls' = 'connection';
|
|
||||||
loading = false;
|
|
||||||
error: string | null = null;
|
|
||||||
info: string | null = null;
|
|
||||||
|
|
||||||
// connection
|
|
||||||
form: any = {};
|
|
||||||
saving = false;
|
|
||||||
|
|
||||||
// API set
|
|
||||||
editing: VendorApi | null = null;
|
|
||||||
isNewApi = false;
|
|
||||||
savingApi = false;
|
|
||||||
editorTab: 'basics' | 'request' | 'response' = 'basics';
|
|
||||||
|
|
||||||
// import from JSON / cURL / Postman / HAR
|
|
||||||
importOpen = false;
|
|
||||||
importRequestText = '';
|
|
||||||
importResponseText = '';
|
|
||||||
importRequestFile = '';
|
|
||||||
importResponseFile = '';
|
|
||||||
importReplace = true;
|
|
||||||
importGenerate = true;
|
|
||||||
importError: string | null = null;
|
|
||||||
|
|
||||||
// test
|
|
||||||
testApiId: number | null = null;
|
|
||||||
testBody = '';
|
|
||||||
testing = false;
|
|
||||||
testResult: ApiLog | null = null;
|
|
||||||
|
|
||||||
// calls
|
|
||||||
calls: ApiLog[] = [];
|
|
||||||
openLog: ApiLog | null = null;
|
|
||||||
|
|
||||||
readonly authTypes = Object.entries(API_AUTH_TYPES).map(
|
|
||||||
([
|
|
||||||
key,
|
|
||||||
label
|
|
||||||
]) => ({ key, label })
|
|
||||||
);
|
|
||||||
readonly contentTypes = Object.entries(API_CONTENT_TYPES).map(
|
|
||||||
([
|
|
||||||
key,
|
|
||||||
label
|
|
||||||
]) => ({ key, label })
|
|
||||||
);
|
|
||||||
readonly transforms = Object.entries(API_TRANSFORMS).map(
|
|
||||||
([
|
|
||||||
key,
|
|
||||||
label
|
|
||||||
]) => ({ key, label })
|
|
||||||
);
|
|
||||||
readonly cbsFields = Object.entries(API_CBS_FIELDS).map(
|
|
||||||
([
|
|
||||||
key,
|
|
||||||
label
|
|
||||||
]) => ({ key, label })
|
|
||||||
);
|
|
||||||
readonly methods = API_METHODS;
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.load(Number(this.route.snapshot.paramMap.get('id')));
|
|
||||||
}
|
|
||||||
|
|
||||||
load(id: number): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.service.vendor(id).subscribe({
|
|
||||||
next: (v) => {
|
|
||||||
this.vendor = v;
|
|
||||||
this.loading = false;
|
|
||||||
this.resetForm(v);
|
|
||||||
if (!this.testApiId || !v.apis.some((a) => a.id === this.testApiId)) this.testApiId = v.apis[0]?.id ?? null;
|
|
||||||
this.fillTestBody();
|
|
||||||
this.loadCalls();
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.loading = false;
|
|
||||||
this.error = e?.error?.defaultUserMessage ?? 'Could not load the vendor.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private resetForm(v: ApiVendor): void {
|
|
||||||
this.form = {
|
|
||||||
name: v.name,
|
|
||||||
work: v.work,
|
|
||||||
environment: v.environment,
|
|
||||||
baseUrl: v.baseUrl,
|
|
||||||
authType: v.authType,
|
|
||||||
username: v.username,
|
|
||||||
apiKeyHeader: v.apiKeyHeader,
|
|
||||||
secret: '',
|
|
||||||
clearSecret: false,
|
|
||||||
senderId: v.senderId,
|
|
||||||
timeoutMs: v.timeoutMs,
|
|
||||||
retries: v.retries,
|
|
||||||
defaultHeaders: JSON.parse(JSON.stringify(v.defaultHeaders)),
|
|
||||||
active: v.active
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
loadCalls(): void {
|
|
||||||
if (!this.vendor) return;
|
|
||||||
this.service.logs({ vendorId: String(this.vendor.id), limit: 12 }).subscribe((r) => (this.calls = r.items));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── connection ──
|
|
||||||
|
|
||||||
needsSecret(): boolean {
|
|
||||||
return this.form.authType !== 'NONE';
|
|
||||||
}
|
|
||||||
|
|
||||||
secretLabel(): string {
|
|
||||||
const a = this.form.authType;
|
|
||||||
return a === 'BASIC'
|
|
||||||
? 'Password'
|
|
||||||
: a === 'API_KEY'
|
|
||||||
? 'API key'
|
|
||||||
: a === 'BEARER_TOKEN'
|
|
||||||
? 'Bearer token'
|
|
||||||
: 'Client secret';
|
|
||||||
}
|
|
||||||
|
|
||||||
saveConnection(): void {
|
|
||||||
if (!this.vendor) return;
|
|
||||||
this.saving = true;
|
|
||||||
this.error = null;
|
|
||||||
this.info = null;
|
|
||||||
const body = { ...this.form };
|
|
||||||
if (!body.secret) delete body.secret;
|
|
||||||
this.service.saveVendor(this.vendor.id, body).subscribe({
|
|
||||||
next: (v) => {
|
|
||||||
this.saving = false;
|
|
||||||
this.vendor = v;
|
|
||||||
this.resetForm(v);
|
|
||||||
this.info = `${v.name} connection saved.`;
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.saving = false;
|
|
||||||
this.error = e?.error?.defaultUserMessage ?? 'Could not save the connection.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── API set ──
|
|
||||||
|
|
||||||
newApi(direction: 'IN' | 'OUT'): void {
|
|
||||||
this.isNewApi = true;
|
|
||||||
this.error = null;
|
|
||||||
this.info = null;
|
|
||||||
this.editorTab = 'basics';
|
|
||||||
this.editing = blankApi(direction);
|
|
||||||
}
|
|
||||||
|
|
||||||
editApi(a: VendorApi): void {
|
|
||||||
this.isNewApi = false;
|
|
||||||
this.error = null;
|
|
||||||
this.info = null;
|
|
||||||
this.editorTab = 'basics';
|
|
||||||
this.editing = JSON.parse(JSON.stringify(a));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the CBS makes the HTTP call (OUT, or IN pull); false for an IN push webhook. */
|
|
||||||
get outbound(): boolean {
|
|
||||||
return !!this.editing && callsVendor(this.editing);
|
|
||||||
}
|
|
||||||
|
|
||||||
directionChanged(): void {
|
|
||||||
if (!this.editing) return;
|
|
||||||
this.editing.mode = this.editing.direction === 'IN' ? (this.editing.mode ?? 'PUSH') : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keeps the path-parameter table in step with the {names} typed in the path. */
|
|
||||||
pathChanged(): void {
|
|
||||||
if (!this.editing) return;
|
|
||||||
const names = pathParamNames(this.editing.path);
|
|
||||||
const kept = this.editing.pathParams.filter((p) => names.includes(p.key));
|
|
||||||
for (const n of names) {
|
|
||||||
if (!kept.some((p) => p.key === n))
|
|
||||||
kept.push({ key: n, value: '', description: '', enabled: true, secret: false });
|
|
||||||
}
|
|
||||||
this.editing.pathParams = kept;
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsOf(side: 'request' | 'response'): ApiMappingRow[] {
|
|
||||||
if (!this.editing) return [];
|
|
||||||
return side === 'request' ? this.editing.requestRows : this.editing.responseRows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the left column of a mapping table holds a CBS value (a pick list) or a path in the vendor's JSON. */
|
|
||||||
fromIsCbs(side: 'request' | 'response'): boolean {
|
|
||||||
return this.outbound ? side === 'request' : side === 'response';
|
|
||||||
}
|
|
||||||
|
|
||||||
mappingTitle(side: 'request' | 'response'): string {
|
|
||||||
if (this.outbound) return side === 'request' ? 'Request mapping (CBS → vendor)' : 'Response mapping (vendor → CBS)';
|
|
||||||
return side === 'request' ? 'Incoming data mapping (vendor → CBS)' : 'Answer mapping (CBS → vendor)';
|
|
||||||
}
|
|
||||||
|
|
||||||
fromLabel(side: 'request' | 'response'): string {
|
|
||||||
return this.fromIsCbs(side) ? 'CBS value' : "Vendor's JSON path";
|
|
||||||
}
|
|
||||||
|
|
||||||
toLabel(side: 'request' | 'response'): string {
|
|
||||||
if (this.fromIsCbs(side)) return side === 'request' ? "Vendor's request path" : 'Answer path';
|
|
||||||
return 'CBS field';
|
|
||||||
}
|
|
||||||
|
|
||||||
private parsedCache = new Map<string, unknown>();
|
|
||||||
|
|
||||||
private parsed(text: string): unknown {
|
|
||||||
if (!this.parsedCache.has(text)) {
|
|
||||||
let v: unknown = undefined;
|
|
||||||
try {
|
|
||||||
v = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
v = undefined;
|
|
||||||
}
|
|
||||||
if (this.parsedCache.size > 20) this.parsedCache.clear();
|
|
||||||
this.parsedCache.set(text, v);
|
|
||||||
}
|
|
||||||
return this.parsedCache.get(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads a.b[0].c out of a parsed payload; undefined when it is not there. */
|
|
||||||
private readPath(root: unknown, path: string): unknown {
|
|
||||||
let cur: any = root;
|
|
||||||
for (const part of path.match(/[^.[\]]+/g) ?? []) {
|
|
||||||
if (cur === null || cur === undefined) return undefined;
|
|
||||||
cur = cur[/^\d+$/.test(part) ? Number(part) : part];
|
|
||||||
}
|
|
||||||
return cur;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How a mapping row matches the sample payload on its side: the value found at the row's JSON path. Rows after a
|
|
||||||
* list (e.g. data.alerts) are read from the first item of that list, as the CBS does for each item.
|
|
||||||
*/
|
|
||||||
sampleMatches(side: 'request' | 'response'): { found: boolean; value: string }[] {
|
|
||||||
if (!this.editing) return [];
|
|
||||||
const body = this.parsed(side === 'request' ? this.editing.requestBody : this.editing.responseBody);
|
|
||||||
const pathIsTo = this.fromIsCbs(side);
|
|
||||||
let item: unknown = undefined;
|
|
||||||
return this.rowsOf(side).map((r) => {
|
|
||||||
const path = (pathIsTo ? r.to : r.from).trim();
|
|
||||||
if (!path || body === undefined) return { found: false, value: body === undefined ? 'payload is not JSON' : '' };
|
|
||||||
let v = this.readPath(body, path);
|
|
||||||
if (v === undefined && item !== undefined) v = this.readPath(item, path);
|
|
||||||
if (Array.isArray(v)) {
|
|
||||||
if (item === undefined && v.length) item = v[0];
|
|
||||||
return { found: true, value: `list of ${v.length}` };
|
|
||||||
}
|
|
||||||
if (v === undefined) return { found: false, value: 'not in the sample payload' };
|
|
||||||
const text = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
||||||
return { found: true, value: text.length > 40 ? text.slice(0, 40) + '…' : text };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
matchedCount(side: 'request' | 'response'): number {
|
|
||||||
return this.sampleMatches(side).filter((m) => m.found).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
addRow(side: 'request' | 'response'): void {
|
|
||||||
this.rowsOf(side).push({ from: '', to: '', transform: 'NONE', defaultValue: '', mandatory: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
removeRow(side: 'request' | 'response', i: number): void {
|
|
||||||
this.rowsOf(side).splice(i, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Builds mapping rows from the payload typed or imported for that side. */
|
|
||||||
generateRows(side: 'request' | 'response'): void {
|
|
||||||
if (!this.editing || !this.vendor) return;
|
|
||||||
const body = side === 'request' ? this.editing.requestBody : this.editing.responseBody;
|
|
||||||
// Reading values OUT of a payload (vendor → CBS) vs putting values INTO one (CBS → vendor).
|
|
||||||
const reading = !this.fromIsCbs(side);
|
|
||||||
const rows = reading ? guessResponseRows(body, this.vendor.code) : guessRequestRows(body);
|
|
||||||
if (!rows.length) {
|
|
||||||
this.error = 'That payload has no JSON values to build a mapping from.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.error = null;
|
|
||||||
const target = this.rowsOf(side);
|
|
||||||
const have = new Set(target.map((r) => r.to));
|
|
||||||
target.push(...rows.filter((r) => !have.has(r.to)));
|
|
||||||
this.info = `${rows.length} mapping row(s) read from the payload. Review the CBS fields and adjust.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveApi(): void {
|
|
||||||
if (!this.vendor || !this.editing) return;
|
|
||||||
this.savingApi = true;
|
|
||||||
this.error = null;
|
|
||||||
this.service.saveApi(this.vendor.id, this.editing).subscribe({
|
|
||||||
next: (a) => {
|
|
||||||
this.savingApi = false;
|
|
||||||
this.info = `${a.name} saved.`;
|
|
||||||
this.editing = null;
|
|
||||||
this.load(this.vendor!.id);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.savingApi = false;
|
|
||||||
this.error = e?.error?.defaultUserMessage ?? 'Could not save the API.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteApi(a: VendorApi): void {
|
|
||||||
if (!this.vendor || !confirm(`Delete the API "${a.name}"?`)) return;
|
|
||||||
this.service.deleteApi(this.vendor.id, a.id).subscribe(() => {
|
|
||||||
this.editing = null;
|
|
||||||
this.info = `${a.name} deleted.`;
|
|
||||||
this.load(this.vendor!.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The request this API would send (or receive), as the Test tab and the editor show it. */
|
|
||||||
preview(a: VendorApi | null): RequestPreview | null {
|
|
||||||
return this.vendor && a ? buildRequestPreview(this.vendor, a) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── import ──
|
|
||||||
|
|
||||||
openImport(): void {
|
|
||||||
this.importOpen = true;
|
|
||||||
this.importError = null;
|
|
||||||
this.importReplace = this.isNewApi;
|
|
||||||
this.importRequestText = '';
|
|
||||||
this.importResponseText = '';
|
|
||||||
this.importRequestFile = '';
|
|
||||||
this.importResponseFile = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
onFile(event: Event, which: 'request' | 'response'): void {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const file = input.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
this.importError = 'The file is larger than 5 MB.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const text = String(reader.result ?? '');
|
|
||||||
if (which === 'request') {
|
|
||||||
this.importRequestText = text;
|
|
||||||
this.importRequestFile = file.name;
|
|
||||||
} else {
|
|
||||||
this.importResponseText = text;
|
|
||||||
this.importResponseFile = file.name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
input.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
applyImport(): void {
|
|
||||||
if (!this.editing || !this.vendor) return;
|
|
||||||
this.importError = null;
|
|
||||||
let req: ImportedRequest | null = null;
|
|
||||||
let res: ImportedResponse | null = null;
|
|
||||||
try {
|
|
||||||
if (this.importRequestText.trim()) req = parseRequestText(this.importRequestText);
|
|
||||||
if (this.importResponseText.trim()) res = parseResponseText(this.importResponseText);
|
|
||||||
else if (req?.response) res = req.response;
|
|
||||||
} catch (e: any) {
|
|
||||||
this.importError = e?.message ?? 'Could not read the file.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!req && !res) {
|
|
||||||
this.importError = 'Paste or choose a request, a response, or both.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const a = this.editing;
|
|
||||||
const done: string[] = [];
|
|
||||||
const notes: string[] = [
|
|
||||||
...(req?.notes ?? []),
|
|
||||||
...(res?.notes ?? [])
|
|
||||||
];
|
|
||||||
const replace = this.importReplace;
|
|
||||||
|
|
||||||
const mergePairs = (target: KeyValue[], incoming: KeyValue[]): KeyValue[] => {
|
|
||||||
if (replace) return incoming;
|
|
||||||
const out = [...target];
|
|
||||||
for (const p of incoming) {
|
|
||||||
const i = out.findIndex((x) => x.key.toLowerCase() === p.key.toLowerCase());
|
|
||||||
if (i >= 0) out[i] = p;
|
|
||||||
else out.push(p);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (req) {
|
|
||||||
if (req.method && this.methods.includes(req.method)) {
|
|
||||||
a.method = req.method as VendorApi['method'];
|
|
||||||
done.push(`method ${req.method}`);
|
|
||||||
}
|
|
||||||
if (req.path && req.path !== '/') {
|
|
||||||
a.path = this.stripVendorBasePath(req.path);
|
|
||||||
done.push(`path ${a.path}`);
|
|
||||||
}
|
|
||||||
if (req.baseUrl) {
|
|
||||||
if (!this.form.baseUrl) {
|
|
||||||
this.form.baseUrl = req.baseUrl;
|
|
||||||
notes.push(`Base URL ${req.baseUrl} was put on the Connection tab - save it there.`);
|
|
||||||
} else if (this.form.baseUrl.replace(/\/+$/, '') !== req.baseUrl) {
|
|
||||||
notes.push(
|
|
||||||
`The file's host (${req.baseUrl}) is not this vendor's base URL (${this.form.baseUrl}); only the path was used.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (req.queryParams.length) {
|
|
||||||
a.queryParams = mergePairs(a.queryParams, req.queryParams);
|
|
||||||
done.push(`${req.queryParams.length} query parameter(s)`);
|
|
||||||
}
|
|
||||||
if (req.headers.length) {
|
|
||||||
a.headers = mergePairs(a.headers, req.headers);
|
|
||||||
done.push(`${req.headers.length} header(s)`);
|
|
||||||
}
|
|
||||||
if (req.contentType) {
|
|
||||||
a.contentType = Object.keys(API_CONTENT_TYPES).includes(req.contentType) ? req.contentType : a.contentType;
|
|
||||||
done.push(`content type ${a.contentType}`);
|
|
||||||
}
|
|
||||||
if (req.body !== undefined) {
|
|
||||||
a.requestBody = req.body;
|
|
||||||
done.push('request payload');
|
|
||||||
}
|
|
||||||
if (!a.name.trim() && a.path) {
|
|
||||||
const last =
|
|
||||||
a.path
|
|
||||||
.split('/')
|
|
||||||
.filter((s) => s && !s.includes('{'))
|
|
||||||
.pop() ?? 'API';
|
|
||||||
a.name = last.charAt(0).toUpperCase() + last.slice(1).replace(/[-_]+/g, ' ');
|
|
||||||
}
|
|
||||||
this.pathChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res) {
|
|
||||||
const failure = res.status !== undefined && (res.status < 200 || res.status > 299);
|
|
||||||
if (res.status !== undefined && !failure) {
|
|
||||||
a.successCodes = String(res.status);
|
|
||||||
done.push(`success code ${res.status}`);
|
|
||||||
}
|
|
||||||
if (res.headers.length) {
|
|
||||||
a.responseHeaders = mergePairs(a.responseHeaders, res.headers);
|
|
||||||
done.push(`${res.headers.length} response header(s)`);
|
|
||||||
}
|
|
||||||
if (res.body !== undefined) {
|
|
||||||
if (failure) {
|
|
||||||
a.errorBody = res.body;
|
|
||||||
done.push(`error body (HTTP ${res.status})`);
|
|
||||||
} else {
|
|
||||||
a.responseBody = res.body;
|
|
||||||
done.push('response body');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (res.errorBody !== undefined) {
|
|
||||||
a.errorBody = res.errorBody;
|
|
||||||
done.push('error body');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.importGenerate) {
|
|
||||||
const addRows = (side: 'request' | 'response', body: string) => {
|
|
||||||
const reading = !this.fromIsCbs(side);
|
|
||||||
const rows = reading ? guessResponseRows(body, this.vendor!.code) : guessRequestRows(body);
|
|
||||||
if (!rows.length) return;
|
|
||||||
const target = replace ? [] : this.rowsOf(side);
|
|
||||||
const have = new Set(target.map((r) => r.to));
|
|
||||||
const merged = [
|
|
||||||
...target,
|
|
||||||
...rows.filter((r) => !have.has(r.to))
|
|
||||||
];
|
|
||||||
if (side === 'request') a.requestRows = merged;
|
|
||||||
else a.responseRows = merged;
|
|
||||||
done.push(`${rows.length} ${side === 'request' ? 'request' : 'response'} mapping row(s)`);
|
|
||||||
};
|
|
||||||
if (req?.body !== undefined) addRows('request', a.requestBody);
|
|
||||||
if (res?.body !== undefined && !(res.status !== undefined && (res.status < 200 || res.status > 299)))
|
|
||||||
addRows('response', a.responseBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.importOpen = false;
|
|
||||||
this.error = null;
|
|
||||||
this.info = `Read from ${[
|
|
||||||
req?.source,
|
|
||||||
res?.source
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' + ')}: ${done.join(', ') || 'nothing new'}. ${notes.join(' ')} Review each tab, then Save API.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A path read from a full URL may repeat the path already in the vendor's base URL. */
|
|
||||||
private stripVendorBasePath(path: string): string {
|
|
||||||
const base = String(this.form.baseUrl ?? '');
|
|
||||||
const m = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(\/.+)$/.exec(base.replace(/\/+$/, ''));
|
|
||||||
if (m && path.startsWith(m[1] + '/')) return path.slice(m[1].length);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── test ──
|
|
||||||
|
|
||||||
get testApi(): VendorApi | null {
|
|
||||||
return this.vendor?.apis.find((x) => x.id === this.testApiId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
fillTestBody(): void {
|
|
||||||
this.testBody = this.testApi?.requestBody ?? '{}';
|
|
||||||
this.testResult = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get testPreview(): RequestPreview | null {
|
|
||||||
const a = this.testApi;
|
|
||||||
return this.vendor && a ? buildRequestPreview(this.vendor, { ...a, requestBody: this.testBody }) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
runTest(): void {
|
|
||||||
if (!this.vendor || !this.testApiId) return;
|
|
||||||
this.testing = true;
|
|
||||||
this.error = null;
|
|
||||||
this.service.testApi(this.vendor.id, this.testApiId, this.testBody).subscribe({
|
|
||||||
next: (log) => {
|
|
||||||
this.testing = false;
|
|
||||||
this.testResult = log;
|
|
||||||
this.loadCalls();
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.testing = false;
|
|
||||||
this.error = e?.error?.defaultUserMessage ?? 'Test failed.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<!--
|
|
||||||
Copyright since 2025 Mifos Initiative
|
|
||||||
|
|
||||||
This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<div class="cic-page">
|
|
||||||
<div class="cic-head">
|
|
||||||
<div>
|
|
||||||
<h1>API Management</h1>
|
|
||||||
<p>
|
|
||||||
Connect any API service to the CBS the same way: <b>Vendor → Work → API set</b>. <b>IN</b> = data comes into the
|
|
||||||
CBS from the vendor (CIC alerts, delivery reports, replies). <b>OUT</b> = the CBS calls the vendor (send an SMS
|
|
||||||
or WhatsApp message).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="cic-actions">
|
|
||||||
<a class="cic-btn" routerLink="/api-management/logs">API call logs</a>
|
|
||||||
<button class="cic-btn primary" (click)="startAdd()">+ Add vendor</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (error && !adding) {
|
|
||||||
<div class="cic-alert error">{{ error }}</div>
|
|
||||||
}
|
|
||||||
@if (info) {
|
|
||||||
<div class="cic-alert success">{{ info }}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="cic-grid cols-4" style="margin-bottom: 16px">
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Vendors</div>
|
|
||||||
<div class="v">{{ vendors.length }}</div>
|
|
||||||
<div class="s">{{ activeVendors }} active</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">APIs</div>
|
|
||||||
<div class="v">{{ totalIn + totalOut }}</div>
|
|
||||||
<div class="s">{{ totalIn }} IN · {{ totalOut }} OUT</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Calls in 24 hours</div>
|
|
||||||
<div class="v">{{ summary.callsToday }}</div>
|
|
||||||
<div class="s">both directions</div>
|
|
||||||
</div>
|
|
||||||
<div class="cic-stat">
|
|
||||||
<div class="k">Failed in 24 hours</div>
|
|
||||||
<div class="v" [class.tone-bad]="summary.failedToday > 0">{{ summary.failedToday }}</div>
|
|
||||||
<div class="s">see the call logs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cic-card">
|
|
||||||
<h2>Vendors</h2>
|
|
||||||
@if (loading && !vendors.length) {
|
|
||||||
<div class="cic-empty">Loading…</div>
|
|
||||||
} @else {
|
|
||||||
<div class="cic-table-wrap">
|
|
||||||
<table class="cic-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Vendor</th>
|
|
||||||
<th>Work</th>
|
|
||||||
<th>APIs</th>
|
|
||||||
<th>Environment</th>
|
|
||||||
<th>24 h calls</th>
|
|
||||||
<th>Last call</th>
|
|
||||||
<th>Active</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (v of vendors; track v.id) {
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<b>{{ v.name }}</b>
|
|
||||||
<div class="muted cic-mono" style="font-size: 11px">{{ v.code }}</div>
|
|
||||||
</td>
|
|
||||||
<td style="max-width: 340px; white-space: normal">{{ v.work }}</td>
|
|
||||||
<td>
|
|
||||||
@if (count(v, 'IN')) {
|
|
||||||
<span class="cic-badge brand">IN × {{ count(v, 'IN') }}</span>
|
|
||||||
}
|
|
||||||
@if (count(v, 'OUT')) {
|
|
||||||
<span class="cic-badge ok">OUT × {{ count(v, 'OUT') }}</span>
|
|
||||||
}
|
|
||||||
@if (!v.apis.length) {
|
|
||||||
<span class="muted">none yet</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
class="cic-badge"
|
|
||||||
[class.warn]="v.environment === 'SIMULATOR'"
|
|
||||||
[class.good]="v.environment === 'PRODUCTION'"
|
|
||||||
>{{ v.environment }}</span
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{{ calls(v).calls }}
|
|
||||||
@if (calls(v).failed) {
|
|
||||||
<span class="tone-bad" style="font-size: 12px"> · {{ calls(v).failed }} failed</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>{{ v.lastCallOn ? (v.lastCallOn | slice: 0 : 16) : '—' }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="cic-switch">
|
|
||||||
<input type="checkbox" [checked]="v.active" (change)="toggle(v, $event)" />
|
|
||||||
<span></span>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style="white-space: nowrap">
|
|
||||||
<a class="cic-btn sm primary" [routerLink]="['/api-management/vendors', v.id]">Set up</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@if (v.links.length) {
|
|
||||||
<tr>
|
|
||||||
<td colspan="8" style="background: var(--cic-soft); padding: 6px 12px">
|
|
||||||
<span class="muted" style="font-size: 12px">{{ v.code }} also has:</span>
|
|
||||||
@for (l of v.links; track l.route) {
|
|
||||||
<a class="cic-btn sm" style="margin-left: 6px" [routerLink]="l.route">{{ l.label }}</a>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (adding) {
|
|
||||||
<div class="cic-modal-backdrop" (click)="adding = false">
|
|
||||||
<div class="cic-modal" (click)="$event.stopPropagation()">
|
|
||||||
<h3>Add an API vendor</h3>
|
|
||||||
@if (error) {
|
|
||||||
<div class="cic-alert error">{{ error }}</div>
|
|
||||||
}
|
|
||||||
<div class="cic-grid cols-1" style="gap: 12px">
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Vendor name</label>
|
|
||||||
<input [(ngModel)]="form.name" placeholder="e.g. Aadhaar eKYC, Payment gateway, E-mail" />
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Work — what is this vendor used for?</label>
|
|
||||||
<textarea rows="3" [(ngModel)]="form.work" placeholder="e.g. Verify customer Aadhaar during KYC"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="cic-field">
|
|
||||||
<label>Environment</label>
|
|
||||||
<select [(ngModel)]="form.environment">
|
|
||||||
<option value="SIMULATOR">SIMULATOR — no network call</option>
|
|
||||||
<option value="UAT">UAT — vendor's test environment</option>
|
|
||||||
<option value="PRODUCTION">PRODUCTION — live</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="muted" style="font-size: 12px; margin: 12px 0 0">
|
|
||||||
Next you set up the connection and add its APIs, each marked IN or OUT.
|
|
||||||
</p>
|
|
||||||
<div class="foot">
|
|
||||||
<button class="cic-btn" (click)="adding = false">Cancel</button>
|
|
||||||
<button class="cic-btn primary" (click)="saveNew()" [disabled]="saving">
|
|
||||||
{{ saving ? 'Adding…' : 'Add and set up' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright since 2025 Mifos Initiative
|
|
||||||
*
|
|
||||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { Component, OnInit, inject } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
import { Router, RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
import { ApiManagementService, ApiVendor } from '../api-management.service';
|
|
||||||
|
|
||||||
/** Every outside API service the CBS uses: vendor, its work, its APIs (IN / OUT) and whether it is switched on. */
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-api-vendors',
|
|
||||||
standalone: true,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
FormsModule,
|
|
||||||
RouterModule
|
|
||||||
],
|
|
||||||
templateUrl: './api-vendors.component.html',
|
|
||||||
styleUrls: ['../../credit-bureau/cic-shared.scss']
|
|
||||||
})
|
|
||||||
export class ApiVendorsComponent implements OnInit {
|
|
||||||
private service = inject(ApiManagementService);
|
|
||||||
private router = inject(Router);
|
|
||||||
|
|
||||||
vendors: ApiVendor[] = [];
|
|
||||||
summary: { callsToday: number; failedToday: number; perVendor: Record<number, { calls: number; failed: number }> } = {
|
|
||||||
callsToday: 0,
|
|
||||||
failedToday: 0,
|
|
||||||
perVendor: {}
|
|
||||||
};
|
|
||||||
loading = false;
|
|
||||||
error: string | null = null;
|
|
||||||
info: string | null = null;
|
|
||||||
|
|
||||||
adding = false;
|
|
||||||
saving = false;
|
|
||||||
form: { name: string; work: string; environment: ApiVendor['environment'] } = this.blankForm();
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
private blankForm() {
|
|
||||||
return { name: '', work: '', environment: 'SIMULATOR' as ApiVendor['environment'] };
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading = true;
|
|
||||||
this.service.vendors().subscribe((v) => {
|
|
||||||
this.vendors = v;
|
|
||||||
this.loading = false;
|
|
||||||
});
|
|
||||||
this.service.summary().subscribe((s) => (this.summary = s));
|
|
||||||
}
|
|
||||||
|
|
||||||
count(v: ApiVendor, direction: 'IN' | 'OUT'): number {
|
|
||||||
return v.apis.filter((a) => a.direction === direction).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalIn(): number {
|
|
||||||
return this.vendors.reduce((s, v) => s + this.count(v, 'IN'), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalOut(): number {
|
|
||||||
return this.vendors.reduce((s, v) => s + this.count(v, 'OUT'), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeVendors(): number {
|
|
||||||
return this.vendors.filter((v) => v.active).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
calls(v: ApiVendor): { calls: number; failed: number } {
|
|
||||||
return this.summary.perVendor[v.id] ?? { calls: 0, failed: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(v: ApiVendor, event: Event): void {
|
|
||||||
const want = (event.target as HTMLInputElement).checked;
|
|
||||||
(event.target as HTMLInputElement).checked = v.active;
|
|
||||||
this.error = null;
|
|
||||||
this.info = null;
|
|
||||||
this.service.saveVendor(v.id, { active: want }).subscribe({
|
|
||||||
next: (u) => {
|
|
||||||
v.active = u.active;
|
|
||||||
this.info = `${u.name} is now ${u.active ? 'active' : 'switched off'}.`;
|
|
||||||
},
|
|
||||||
error: (e) => (this.error = e?.error?.defaultUserMessage ?? 'Could not change the vendor.')
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
startAdd(): void {
|
|
||||||
this.adding = true;
|
|
||||||
this.form = this.blankForm();
|
|
||||||
this.error = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveNew(): void {
|
|
||||||
this.saving = true;
|
|
||||||
this.error = null;
|
|
||||||
this.service.addVendor(this.form).subscribe({
|
|
||||||
next: (v) => {
|
|
||||||
this.saving = false;
|
|
||||||
this.adding = false;
|
|
||||||
this.router.navigate([
|
|
||||||
'/api-management/vendors',
|
|
||||||
v.id
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
error: (e) => {
|
|
||||||
this.saving = false;
|
|
||||||
this.error = e?.error?.defaultUserMessage ?? 'Could not add the vendor.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -73,13 +73,10 @@ import { InvestmentModule } from './investment/investment.module';
|
|||||||
import { NpaModule } from './npa/npa.module';
|
import { NpaModule } from './npa/npa.module';
|
||||||
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
import { ReconciliationModule } from './reconciliation/reconciliation.module';
|
||||||
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
import { LoanOriginationModule } from './loan-origination/loan-origination.module';
|
||||||
import { CreditBureauModule } from './credit-bureau/credit-bureau.module';
|
|
||||||
import { ApiManagementModule } from './api-management/api-management.module';
|
|
||||||
import { DigitalModule } from './digital/digital.module';
|
import { DigitalModule } from './digital/digital.module';
|
||||||
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
import { LetterMgmtModule } from './letter-mgmt/letter-mgmt.module';
|
||||||
import { AccountsModule } from './accounts/accounts.module';
|
import { AccountsModule } from './accounts/accounts.module';
|
||||||
import { ElectionModule } from './election/election.module';
|
import { ElectionModule } from './election/election.module';
|
||||||
import { LoanEligibilityModule } from './loan-eligibility/loan-eligibility.module';
|
|
||||||
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
|
import { SponsorDDModule } from './sponsor-dd/sponsor-dd.module';
|
||||||
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
|
import { SponsorDDReconciliationModule } from './sponsor-dd-reconciliation/sponsor-dd-reconciliation.module';
|
||||||
import { PortalModule } from '@angular/cdk/portal';
|
import { PortalModule } from '@angular/cdk/portal';
|
||||||
@@ -192,13 +189,10 @@ export function HttpLoaderFactory(http: HttpClient) {
|
|||||||
NpaModule,
|
NpaModule,
|
||||||
ReconciliationModule,
|
ReconciliationModule,
|
||||||
LoanOriginationModule,
|
LoanOriginationModule,
|
||||||
CreditBureauModule,
|
|
||||||
ApiManagementModule,
|
|
||||||
DigitalModule,
|
DigitalModule,
|
||||||
LetterMgmtModule,
|
LetterMgmtModule,
|
||||||
AccountsModule,
|
AccountsModule,
|
||||||
ElectionModule,
|
ElectionModule,
|
||||||
LoanEligibilityModule,
|
|
||||||
SponsorDDModule,
|
SponsorDDModule,
|
||||||
SponsorDDReconciliationModule,
|
SponsorDDReconciliationModule,
|
||||||
AppRoutingModule,
|
AppRoutingModule,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { of } from 'rxjs';
|
|||||||
import { catchError } from 'rxjs/operators';
|
import { catchError } from 'rxjs/operators';
|
||||||
import { CashierService } from '../cashier.service';
|
import { CashierService } from '../cashier.service';
|
||||||
import { businessToday } from 'app/core/utils/business-today';
|
import { businessToday } from 'app/core/utils/business-today';
|
||||||
import { AuthenticationService } from '../../core/authentication/authentication.service';
|
|
||||||
|
|
||||||
interface Denom {
|
interface Denom {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -55,7 +54,6 @@ export class CashTransferComponent implements OnInit {
|
|||||||
|
|
||||||
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
|
@ViewChild('requestsDialog') requestsDialog!: TemplateRef<unknown>;
|
||||||
private requestsRef: MatDialogRef<unknown> | null = null;
|
private requestsRef: MatDialogRef<unknown> | null = null;
|
||||||
private authenticationService = inject(AuthenticationService);
|
|
||||||
|
|
||||||
notes: Denom[] = [];
|
notes: Denom[] = [];
|
||||||
coins: Denom[] = [];
|
coins: Denom[] = [];
|
||||||
@@ -141,14 +139,7 @@ export class CashTransferComponent implements OnInit {
|
|||||||
.getTellers()
|
.getTellers()
|
||||||
.pipe(catchError(() => of([] as any[])))
|
.pipe(catchError(() => of([] as any[])))
|
||||||
.subscribe((list) => {
|
.subscribe((list) => {
|
||||||
// The vault and its cash are physical, so a branch moves cash only from its own tellers. The
|
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
|
||||||
// backend lists every teller below the user's office (Head Office sees all branches), which is
|
|
||||||
// right for reports but wrong here.
|
|
||||||
const myOfficeId = this.authenticationService.getCredentials()?.officeId;
|
|
||||||
const own = (list || []).filter(
|
|
||||||
(t: any) => !myOfficeId || t.officeId == null || Number(t.officeId) === Number(myOfficeId)
|
|
||||||
);
|
|
||||||
this.tellers = own.map((t: any) => ({ id: t.id, name: t.name }));
|
|
||||||
if (this.tellers.length === 1) {
|
if (this.tellers.length === 1) {
|
||||||
this.onTellerSelect(this.tellers[0].id);
|
this.onTellerSelect(this.tellers[0].id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,12 +132,12 @@
|
|||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.br"
|
[(ngModel)]="row.br"
|
||||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
[disabled]="branches.length <= 1"
|
||||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
@for (b of branchSelectOptions; track b.id) {
|
@for (b of branches; track b.id) {
|
||||||
<option [ngValue]="b.id">{{ b.name }}</option>
|
<option [ngValue]="b.id">{{ b.label }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -722,24 +722,7 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<div class="te-panel__body">
|
<div class="te-panel__body">
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">Branch</span
|
<span class="te-label">Branch</span><span class="te-value">{{ officeName }}</span>
|
||||||
><span class="te-value">
|
|
||||||
@if (isOwnBranchAccount !== null) {
|
|
||||||
<span
|
|
||||||
class="te-branch-chip"
|
|
||||||
[class.te-branch-chip--mine]="isOwnBranchAccount"
|
|
||||||
[class.te-branch-chip--other]="!isOwnBranchAccount"
|
|
||||||
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
|
|
||||||
>
|
|
||||||
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
|
|
||||||
@if (!isOwnBranchAccount) {
|
|
||||||
<span class="te-branch-chip__tag">Other branch</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
{{ officeName }}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="te-field">
|
<div class="te-field">
|
||||||
<span class="te-label">A/c No.</span
|
<span class="te-label">A/c No.</span
|
||||||
@@ -1218,24 +1201,7 @@
|
|||||||
: '—')
|
: '—')
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
<span class="te-acs-kv"
|
<span class="te-acs-kv"><b>Branch</b> {{ officeName ?? '—' }}</span>
|
||||||
><b>Branch</b>
|
|
||||||
@if (isOwnBranchAccount !== null) {
|
|
||||||
<span
|
|
||||||
class="te-branch-chip"
|
|
||||||
[class.te-branch-chip--mine]="isOwnBranchAccount"
|
|
||||||
[class.te-branch-chip--other]="!isOwnBranchAccount"
|
|
||||||
[title]="isOwnBranchAccount ? 'Held at your branch' : 'Held at another branch'"
|
|
||||||
>
|
|
||||||
<i class="fas fa-map-marker-alt"></i>{{ officeName }}
|
|
||||||
@if (!isOwnBranchAccount) {
|
|
||||||
<span class="te-branch-chip__tag">Other branch</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
{{ officeName }}
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<span class="te-acs-kv"
|
<span class="te-acs-kv"
|
||||||
><b>Product</b>
|
><b>Product</b>
|
||||||
{{
|
{{
|
||||||
@@ -1626,12 +1592,12 @@
|
|||||||
<select
|
<select
|
||||||
class="ws-f__ctl"
|
class="ws-f__ctl"
|
||||||
[(ngModel)]="row.br"
|
[(ngModel)]="row.br"
|
||||||
[disabled]="branches.length <= 1 && branchSelectOptions.length <= 1"
|
[disabled]="branches.length <= 1"
|
||||||
[title]="branches.length <= 1 && branchSelectOptions.length <= 1 ? 'Your branch' : 'Branch'"
|
[title]="branches.length <= 1 ? 'Your branch' : 'Branch'"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
@for (b of branchSelectOptions; track b.id) {
|
@for (b of branches; track b.id) {
|
||||||
<option [ngValue]="b.id">{{ b.name }}</option>
|
<option [ngValue]="b.id">{{ b.label }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3462,39 +3462,3 @@ select.te-cell {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The branch an account is held at, coloured by where the signed-in user works: green for their own branch, red for any other. */
|
|
||||||
.te-branch-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&--mine {
|
|
||||||
background: #dcfce7;
|
|
||||||
border-color: #4ade80;
|
|
||||||
color: #166534;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--other {
|
|
||||||
background: #fca5a5;
|
|
||||||
border-color: #f87171;
|
|
||||||
color: #7f1d1d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.te-branch-chip__tag {
|
|
||||||
padding: 0 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgb(255 255 255 / 80%);
|
|
||||||
color: #7f1d1d;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import { Component, OnInit, OnDestroy, inject, Input, ElementRef, ViewChild } fr
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
|
|
||||||
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
|
import { catchError, debounceTime, distinctUntilChanged, map, timeout } from 'rxjs/operators';
|
||||||
import { forkJoin, of, Subject } from 'rxjs';
|
import { forkJoin, of, Subject } from 'rxjs';
|
||||||
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
@@ -1880,7 +1879,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
|
||||||
this.loadBranches();
|
this.loadBranches();
|
||||||
this.addRow();
|
this.addRow();
|
||||||
this.loadBranchOptions();
|
|
||||||
this.loadDenominations();
|
this.loadDenominations();
|
||||||
this.loadOpeningPosition();
|
this.loadOpeningPosition();
|
||||||
this.loadDayStatus();
|
this.loadDayStatus();
|
||||||
@@ -2712,8 +2710,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
branchNameFor(branchId: number): string {
|
branchNameFor(branchId: number): string {
|
||||||
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}`;
|
return this.branches.find((b) => b.id === branchId)?.label ?? `Branch ${branchId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4064,8 +4060,11 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(catchError(() => of(null)))
|
.pipe(catchError(() => of(null)))
|
||||||
.subscribe((res: any) => {
|
.subscribe((res: any) => {
|
||||||
if (token !== this.searchToken) return; // stale result — newer search started
|
if (token !== this.searchToken) return; // stale result — newer search started
|
||||||
const allItems: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
const found: any[] = res?.pageItems ?? (Array.isArray(res) ? res : []);
|
||||||
const f = cfg.matchField;
|
const f = cfg.matchField;
|
||||||
|
// SD, FD and RD share one list that returns any kind of deposit account: keep only the kind chosen in A/C TP.
|
||||||
|
const allItems =
|
||||||
|
cfg.depositTypeId != null ? found.filter((x: any) => x?.depositType?.id === cfg.depositTypeId) : found;
|
||||||
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
// Exact match first, then suffix/partial match. Some account types (e.g. Share — its
|
||||||
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
// list endpoint only accepts offset/limit, no accountNo filter at all) always return the
|
||||||
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
const exactMatch = allItems.find((x: any) => String(x[f] ?? '').trim() === String(acNo).trim());
|
||||||
@@ -4095,7 +4094,18 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loadingAccount = false;
|
this.loadingAccount = false;
|
||||||
this.accountError = 'Account not found for: ' + acNo;
|
// The number may exist as another kind of account (an FD typed under SD, say): say so, so the clerk can fix A/C TP.
|
||||||
|
const otherKind =
|
||||||
|
cfg.depositTypeId != null
|
||||||
|
? found.find(
|
||||||
|
(x: any) =>
|
||||||
|
x?.depositType?.id !== cfg.depositTypeId &&
|
||||||
|
(String(x[f] ?? '').trim() === String(acNo).trim() || String(x[f] ?? '').endsWith(acNo))
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
this.accountError = otherKind
|
||||||
|
? `${acTyp} account not found for: ${acNo} (this number is a ${otherKind.depositType?.value ?? 'different kind of'} account - change A/C TP)`
|
||||||
|
: `${acTyp} account not found for: ${acNo}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4159,6 +4169,10 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
|
* account type — used to keep Header GL accounts out of the results a clerk/cashier can pick
|
||||||
* for a transaction; a Header can never receive a posting, so it should never be offered. */
|
* for a transaction; a Header can never receive a posting, so it should never be offered. */
|
||||||
extraListQueryParams?: string;
|
extraListQueryParams?: string;
|
||||||
|
/** SD, FD and RD are all served by the one /savingsaccounts list, which answers with a deposit
|
||||||
|
* account of any kind for a number. This is the depositType.id the chosen A/C type accepts
|
||||||
|
* (100 savings, 200 fixed, 300 recurring), so a number of another kind is "not found" here. */
|
||||||
|
depositTypeId?: number;
|
||||||
} {
|
} {
|
||||||
const t = acTyp?.toUpperCase();
|
const t = acTyp?.toUpperCase();
|
||||||
if (t === 'LA') {
|
if (t === 'LA') {
|
||||||
@@ -4239,6 +4253,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
list: '/savingsaccounts',
|
list: '/savingsaccounts',
|
||||||
detail: '/fixeddepositaccounts',
|
detail: '/fixeddepositaccounts',
|
||||||
|
depositTypeId: 200,
|
||||||
nominee: 'FD',
|
nominee: 'FD',
|
||||||
search: 'fixeddeposit',
|
search: 'fixeddeposit',
|
||||||
siAccountType: 2,
|
siAccountType: 2,
|
||||||
@@ -4256,6 +4271,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
list: '/savingsaccounts',
|
list: '/savingsaccounts',
|
||||||
detail: '/recurringdepositaccounts',
|
detail: '/recurringdepositaccounts',
|
||||||
|
depositTypeId: 300,
|
||||||
nominee: 'RD',
|
nominee: 'RD',
|
||||||
search: 'recurringdeposit',
|
search: 'recurringdeposit',
|
||||||
siAccountType: 2,
|
siAccountType: 2,
|
||||||
@@ -4273,6 +4289,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
list: '/savingsaccounts',
|
list: '/savingsaccounts',
|
||||||
detail: '/savingsaccounts',
|
detail: '/savingsaccounts',
|
||||||
|
depositTypeId: 100,
|
||||||
nominee: 'SB',
|
nominee: 'SB',
|
||||||
search: 'saving',
|
search: 'saving',
|
||||||
siAccountType: 2,
|
siAccountType: 2,
|
||||||
@@ -4332,7 +4349,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
this.accountDetails = acc;
|
this.accountDetails = acc;
|
||||||
this.resolveAccountBranch(acc, acTyp, token, this.selectedRow);
|
|
||||||
this.jointHolders = acc.jointHolders ?? [];
|
this.jointHolders = acc.jointHolders ?? [];
|
||||||
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
|
// GL only: look up the parent (header) GL's name. Never runs for any other account type.
|
||||||
this.glParent = null;
|
this.glParent = null;
|
||||||
@@ -4949,98 +4965,6 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the account on screen is held at the branch the signed-in user works at: true for their own branch, false for
|
|
||||||
* another branch's, null while either branch is not known (the branch is then shown plain, uncoloured).
|
|
||||||
*/
|
|
||||||
get isOwnBranchAccount(): boolean | null {
|
|
||||||
const mine = this.authService.getCredentials()?.officeId;
|
|
||||||
const accountOffice = this.accountDetails?.officeId ?? this.clientDetails?.officeId;
|
|
||||||
if (mine == null || accountOffice == null) return null;
|
|
||||||
return Number(mine) === Number(accountOffice);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 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);
|
|
||||||
}
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
* names each account's branch, so it is read from there. A current account's own response already carries it.
|
|
||||||
*/
|
|
||||||
private resolveAccountBranch(acc: any, acTyp: string, token: number, row: TxnRow | null | undefined): void {
|
|
||||||
if (acc?.officeId != null && acc?.officeName) {
|
|
||||||
this.adoptAccountBranch({ id: Number(acc.officeId), name: String(acc.officeName) }, row);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const type = (acTyp ?? '').toUpperCase();
|
|
||||||
if (!acc?.id || !acc?.clientId || ![
|
|
||||||
'SD',
|
|
||||||
'FD',
|
|
||||||
'RD',
|
|
||||||
'CA'
|
|
||||||
].includes(type)) return;
|
|
||||||
this.http
|
|
||||||
.get<any>(`/clients/${acc.clientId}/accounts`, { context: new HttpContext().set(SILENT_HTTP_ERROR, true) })
|
|
||||||
.pipe(catchError(() => of(null)))
|
|
||||||
.subscribe((res) => {
|
|
||||||
// stale: another account was picked while this was loading
|
|
||||||
if (token !== this.searchToken || this.accountDetails?.id !== acc.id) return;
|
|
||||||
const list: any[] = (type === 'CA' ? res?.currentAccounts : res?.savingsAccounts) ?? [];
|
|
||||||
const match = list.find((a: any) => a.id === acc.id);
|
|
||||||
if (match?.officeId != null && match?.officeName) {
|
|
||||||
this.adoptAccountBranch({ id: Number(match.officeId), name: String(match.officeName) }, row);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private adoptAccountBranch(branch: { id: number; name: string }, row: TxnRow | null | undefined): void {
|
|
||||||
this.accountDetails = { ...this.accountDetails, officeId: branch.id, officeName: branch.name };
|
|
||||||
// /offices lists only the signed-in user's own branches, or nothing for a user who may not read offices; the account's
|
|
||||||
// branch must still be selectable, under its real name
|
|
||||||
if (!this.seenBranches.some((b) => b.id === branch.id)) {
|
|
||||||
this.seenBranches = [
|
|
||||||
...this.seenBranches,
|
|
||||||
branch
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (row) row.br = branch.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
get openingDate(): string {
|
get openingDate(): string {
|
||||||
const raw =
|
const raw =
|
||||||
this.accountDetails?.timeline?.submittedOnDate ??
|
this.accountDetails?.timeline?.submittedOnDate ??
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ import { ReportMuleComponent } from './report-mule/report-mule.component';
|
|||||||
import { ReportFraudTxnComponent } from './report-fraud-txn/report-fraud-txn.component';
|
import { ReportFraudTxnComponent } from './report-fraud-txn/report-fraud-txn.component';
|
||||||
import { FmrSubmissionComponent } from './fmr-submission/fmr-submission.component';
|
import { FmrSubmissionComponent } from './fmr-submission/fmr-submission.component';
|
||||||
import { CfrLookupComponent } from './cfr-lookup/cfr-lookup.component';
|
import { CfrLookupComponent } from './cfr-lookup/cfr-lookup.component';
|
||||||
|
import { I4cShellComponent } from './i4c/i4c-shell/i4c-shell.component';
|
||||||
|
import { I4cDashboardComponent } from './i4c/i4c-dashboard/i4c-dashboard.component';
|
||||||
|
import { I4cRequestsComponent } from './i4c/i4c-requests/i4c-requests.component';
|
||||||
|
import { I4cRequestDetailComponent } from './i4c/i4c-request-detail/i4c-request-detail.component';
|
||||||
|
import { I4cHoldsComponent } from './i4c/i4c-holds/i4c-holds.component';
|
||||||
|
import { I4cApprovalsComponent } from './i4c/i4c-approvals/i4c-approvals.component';
|
||||||
|
import { I4cOutboxComponent } from './i4c/i4c-outbox/i4c-outbox.component';
|
||||||
|
import { I4cBankMasterComponent } from './i4c/i4c-bank-master/i4c-bank-master.component';
|
||||||
|
import { I4cIntimationSettingsComponent } from './i4c/i4c-intimation-settings/i4c-intimation-settings.component';
|
||||||
|
import { I4cGrievancesComponent } from './i4c/i4c-grievances/i4c-grievances.component';
|
||||||
|
import { I4cRestorationsComponent } from './i4c/i4c-restorations/i4c-restorations.component';
|
||||||
|
import { I4cMrmReportComponent } from './i4c/i4c-mrm-report/i4c-mrm-report.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
Route.withShell([
|
Route.withShell([
|
||||||
@@ -76,6 +88,67 @@ const routes: Routes = [
|
|||||||
path: 'cfr-lookup',
|
path: 'cfr-lookup',
|
||||||
component: CfrLookupComponent,
|
component: CfrLookupComponent,
|
||||||
data: { title: 'CFR Lookup', breadcrumb: 'CFR Lookup' }
|
data: { title: 'CFR Lookup', breadcrumb: 'CFR Lookup' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Live I4C-MHA integration (NCRP-CFCFRMS API). Served by /v1/i4c/admin on the CBS; the demo mock
|
||||||
|
// interceptor does not match these paths.
|
||||||
|
path: 'i4c',
|
||||||
|
component: I4cShellComponent,
|
||||||
|
data: { title: 'I4C Live Integration', breadcrumb: 'I4C Live' },
|
||||||
|
children: [
|
||||||
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
component: I4cDashboardComponent,
|
||||||
|
data: { title: 'I4C Dashboard', breadcrumb: 'Dashboard' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'requests',
|
||||||
|
component: I4cRequestsComponent,
|
||||||
|
data: { title: 'Complaints from I4C', breadcrumb: 'Complaints' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'requests/:id',
|
||||||
|
component: I4cRequestDetailComponent,
|
||||||
|
data: { title: 'I4C Complaint', breadcrumb: 'Complaint' }
|
||||||
|
},
|
||||||
|
{ path: 'holds', component: I4cHoldsComponent, data: { title: 'I4C Holds Register', breadcrumb: 'Holds' } },
|
||||||
|
{
|
||||||
|
path: 'approvals',
|
||||||
|
component: I4cApprovalsComponent,
|
||||||
|
data: { title: 'I4C Approvals', breadcrumb: 'Approvals' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'outbox',
|
||||||
|
component: I4cOutboxComponent,
|
||||||
|
data: { title: 'I4C Responses', breadcrumb: 'Responses' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'bank-master',
|
||||||
|
component: I4cBankMasterComponent,
|
||||||
|
data: { title: 'I4C Bank Code Master', breadcrumb: 'Bank Master' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'grievances',
|
||||||
|
component: I4cGrievancesComponent,
|
||||||
|
data: { title: 'I4C GRM Grievances', breadcrumb: 'GRM Grievances' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'restorations',
|
||||||
|
component: I4cRestorationsComponent,
|
||||||
|
data: { title: 'I4C MRM Restorations', breadcrumb: 'MRM Restorations' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mrm-report',
|
||||||
|
component: I4cMrmReportComponent,
|
||||||
|
data: { title: 'I4C MRM Balance Report', breadcrumb: 'MRM Balance Report' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'intimation-settings',
|
||||||
|
component: I4cIntimationSettingsComponent,
|
||||||
|
data: { title: 'I4C Intimation Settings', breadcrumb: 'Intimation Settings' }
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,17 @@
|
|||||||
<fa-icon icon="chart-line" class="cfc-tab__ic"></fa-icon>
|
<fa-icon icon="chart-line" class="cfc-tab__ic"></fa-icon>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
mat-tab-link
|
||||||
|
class="cfc-tab"
|
||||||
|
[routerLink]="['./i4c']"
|
||||||
|
routerLinkActive
|
||||||
|
#i4cLive="routerLinkActive"
|
||||||
|
[active]="i4cLive.isActive"
|
||||||
|
>
|
||||||
|
<fa-icon icon="satellite-dish" class="cfc-tab__ic"></fa-icon>
|
||||||
|
<span>I4C Live</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
mat-tab-link
|
mat-tab-link
|
||||||
class="cfc-tab"
|
class="cfc-tab"
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* 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 by the GRM grievance, MRM restoration and MRM report pages.
|
||||||
|
@mixin i4c-case-page {
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__wide {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px dashed #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
&__stage {
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--done {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #047857;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--now {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__arrow {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deadline {
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--late {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Modern design system for the I4C (NCRP-CFCFRMS) subsystem:
|
||||||
|
Glassmorphism, sleek modern cards, rich data tables, glowing status pills,
|
||||||
|
and animated popup window modals. */
|
||||||
|
|
||||||
|
@mixin i4c-skin {
|
||||||
|
.i4c-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
animation: i4c-fade-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Toolbar */
|
||||||
|
.i4c-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgb(15 23 42 / 3%);
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra filters and the date range, under the toolbar */
|
||||||
|
.i4c-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside a card, under its head
|
||||||
|
&--inset {
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paginator at the foot of a list card */
|
||||||
|
.i4c-paginator {
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter Chip bar */
|
||||||
|
.i4c-filter-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 3px 8px rgb(37 99 235 / 25%);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip-count {
|
||||||
|
background: rgb(255 255 255 / 25%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-chip-count {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern KPI Cards */
|
||||||
|
.i4c-kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-kpi {
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 4px 10px rgb(15 23 42 / 3%);
|
||||||
|
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 18px -4px rgb(15 23 42 / 8%);
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green::before {
|
||||||
|
background: linear-gradient(90deg, #10b981, #34d399);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber::before {
|
||||||
|
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red::before {
|
||||||
|
background: linear-gradient(90deg, #ef4444, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--purple::before {
|
||||||
|
background: linear-gradient(90deg, #8b5cf6, #a78bfa);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Card Container */
|
||||||
|
.i4c-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 14px rgb(15 23 42 / 3%);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 6px 18px rgb(15 23 42 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: linear-gradient(180deg, #fafbfd 0%, #f4f6fa 100%);
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Data Table */
|
||||||
|
table.i4c-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
color: #1e293b;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover td {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.selected td {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-bottom-color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glowing Modern Status Pills */
|
||||||
|
.i4c-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #10b981;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #fde68a;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #f59e0b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border-color: #fecaca;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #ef4444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--purple {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
box-shadow: 0 0 6px #8b5cf6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Key-Value details layout */
|
||||||
|
.i4c-kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
gap: 8px 12px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-empty {
|
||||||
|
padding: 30px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-note {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sleek Dark JSON / Code Viewer */
|
||||||
|
pre.i4c-json {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px 20px;
|
||||||
|
max-height: 380px;
|
||||||
|
overflow: auto;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #38bdf8;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: 'JetBrains Mono', Consolas, Monaco, monospace;
|
||||||
|
font-size: 10.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
box-shadow: inset 0 2px 4px rgb(0 0 0 / 40%);
|
||||||
|
box-sizing: border-box;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #475569;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick action button inside tables */
|
||||||
|
.table-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2563eb;
|
||||||
|
background: #eff6ff;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Copy Button */
|
||||||
|
.copy-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expandable JSON Row inside tables */
|
||||||
|
.finding-json-cell {
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
background: #0b1329 !important;
|
||||||
|
border-bottom: 2px solid #1e293b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-json-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.finding-json-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.i4c-json {
|
||||||
|
max-height: none;
|
||||||
|
overflow: auto visible;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 0 16px;
|
||||||
|
box-shadow: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================
|
||||||
|
MODERN POPUP WINDOW MODAL SYSTEM (Ultra Modern Backdrop & Window)
|
||||||
|
======================================================== */
|
||||||
|
.i4c-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(15 23 42 / 65%);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 16px;
|
||||||
|
animation: i4c-fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-window {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgb(255 255 255 / 20%);
|
||||||
|
box-shadow: 0 20px 50px -15px rgb(15 23 42 / 35%);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 660px;
|
||||||
|
max-height: 86vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: i4c-modal-pop 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
&--wide {
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--full {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 88vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
max-width: 440px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 11px 16px;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid rgb(255 255 255 / 10%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgb(255 255 255 / 10%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgb(239 68 68 / 80%);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
overflow-x: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.modal-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
color: #2563eb;
|
||||||
|
background: #fff;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-body {
|
||||||
|
padding: 14px 16px;
|
||||||
|
overflow: hidden auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
|
||||||
|
/* Content keeps its natural height and the body scrolls; a flex child would otherwise shrink to fit and
|
||||||
|
clip what is inside it (a card has overflow: hidden). */
|
||||||
|
> div {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes i4c-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes i4c-modal-pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.92) translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<!-- Modern Toolbar & Filter Chips -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
@for (s of statuses; track s) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="status === s" (click)="setStatus(s)">
|
||||||
|
<span>{{ s || 'All Requests' }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<div class="maker-checker-pill">
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
<span>Maker-Checker Enforced: you cannot approve a request you raised.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" class="refresh-btn" (click)="load()" [disabled]="loading">
|
||||||
|
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-field">
|
||||||
|
<mat-label>Account / ack / order no. / officer</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="search"
|
||||||
|
(keyup.enter)="load(true)"
|
||||||
|
(blur)="load(true)"
|
||||||
|
placeholder="Type & hit enter..."
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Action</mat-label>
|
||||||
|
<mat-select [(ngModel)]="action" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All actions</mat-option>
|
||||||
|
<mat-option value="RELEASE">Release hold</mat-option>
|
||||||
|
<mat-option value="FREEZE">Freeze debits</mat-option>
|
||||||
|
<mat-option value="UNFREEZE">Unfreeze debits</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Reason</mat-label>
|
||||||
|
<mat-select [(ngModel)]="reasonType" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All reasons</mat-option>
|
||||||
|
@for (r of reasonTypes; track r.value) {
|
||||||
|
<mat-option [value]="r.value">{{ r.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mifosx-i4c-date-range label="Raised on" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Approvals Table Card -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="user-check"></fa-icon>
|
||||||
|
<span>Checker Approval Queue (I4C Account Holds)</span>
|
||||||
|
<span class="head-hint">{{ total }} requests in current queue</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Raised On</th>
|
||||||
|
<th>Requested Action</th>
|
||||||
|
<th>Target Account / Complaint</th>
|
||||||
|
<th class="num">Held Amount (₹)</th>
|
||||||
|
<th>Reason & Legal Order</th>
|
||||||
|
<th>Maker Remarks</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Decision Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rows; track r.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="date-main">{{ r.maker_at | date: 'dd MMM yyyy, HH:mm' }}</span>
|
||||||
|
<div class="sub">
|
||||||
|
Raised by: <b>{{ r.maker_name }}</b>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--red]="r.action !== 'RELEASE'"
|
||||||
|
[class.i4c-badge--blue]="r.action === 'RELEASE'"
|
||||||
|
>
|
||||||
|
{{ r.action }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a [routerLink]="['../holds']" [queryParams]="{ view: '' }" class="acc-link font-semibold">
|
||||||
|
{{ r.account_no }}
|
||||||
|
</a>
|
||||||
|
<div class="sub">{{ r.account_type }} • Ack: {{ r.acknowledgement_no }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
<span class="font-bold text-accent">₹{{ r.amount_held | number: '1.2-2' }}</span>
|
||||||
|
<div class="sub">Disputed: ₹{{ r.disputed_amount | number: '1.2-2' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="reason-title">{{ reasonLabel(r.reason_type) }}</div>
|
||||||
|
<div class="sub">
|
||||||
|
Ref: {{ r.order_reference }} {{ r.order_date ? '(' + r.order_date + ')' : '' }}
|
||||||
|
{{ r.authority ? '• ' + r.authority : '' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="remarks-text">{{ r.remarks || '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="r.status === 'APPROVED'"
|
||||||
|
[class.i4c-badge--red]="r.status === 'REJECTED' || r.status === 'FAILED'"
|
||||||
|
[class.i4c-badge--amber]="r.status === 'PENDING'"
|
||||||
|
>
|
||||||
|
{{ r.status }}
|
||||||
|
</span>
|
||||||
|
@if (r.checker_name) {
|
||||||
|
<div class="sub">Checked by {{ r.checker_name }}, {{ r.checker_at | date: 'dd MMM, HH:mm' }}</div>
|
||||||
|
}
|
||||||
|
@if (r.rejection_reason || r.failure_message) {
|
||||||
|
<div class="sub err-text">{{ r.rejection_reason || r.failure_message }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
@if (r.status === 'PENDING') {
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button class="approve-btn" (click)="startApprove(r)" [disabled]="busyId === r.id">
|
||||||
|
<fa-icon icon="check"></fa-icon> Approve
|
||||||
|
</button>
|
||||||
|
<button class="reject-btn" (click)="startReject(r)" [disabled]="busyId === r.id">
|
||||||
|
<fa-icon icon="times"></fa-icon> Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted">Decision Recorded</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="user-check"></fa-icon></div>
|
||||||
|
<div>No approval requests in this category.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Approval requests pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: APPROVE CONFIRMATION MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (approvingRow) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeApprove()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--compact" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header approve-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="check-circle" class="text-green"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<span>Confirm Checker Approval</span>
|
||||||
|
<div class="i4c-modal-header__sub">Executing live CBS account action</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeApprove()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="approve-card">
|
||||||
|
<div class="approve-action-badge">
|
||||||
|
<span
|
||||||
|
>ACTION: <b>{{ approvingRow.action }}</b></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Account Number</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ approvingRow.account_no }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Held Amount</dt>
|
||||||
|
<dd>
|
||||||
|
<b class="text-accent">₹{{ approvingRow.amount_held | number: '1.2-2' }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Reason</dt>
|
||||||
|
<dd>{{ reasonLabel(approvingRow.reason_type) }}</dd>
|
||||||
|
|
||||||
|
<dt>Order Reference</dt>
|
||||||
|
<dd>
|
||||||
|
{{ approvingRow.order_reference }}
|
||||||
|
{{ approvingRow.authority ? '(' + approvingRow.authority + ')' : '' }}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Maker Details</dt>
|
||||||
|
<dd>{{ approvingRow.maker_name }} on {{ approvingRow.maker_at | date: 'dd MMM yyyy, HH:mm' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-note warn-note">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
<span
|
||||||
|
>Warning: Once confirmed, the hold change or freeze will be executed immediately on the core banking
|
||||||
|
system.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeApprove()">Cancel</button>
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="primary"
|
||||||
|
class="confirm-approve-btn"
|
||||||
|
(click)="confirmApprove()"
|
||||||
|
[disabled]="busyId === approvingRow.id"
|
||||||
|
>
|
||||||
|
<fa-icon
|
||||||
|
[icon]="busyId === approvingRow.id ? 'sync' : 'check'"
|
||||||
|
[animation]="busyId === approvingRow.id ? 'spin' : undefined"
|
||||||
|
></fa-icon>
|
||||||
|
Confirm & Approve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: REJECT REASON MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (rejectingRow) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeReject()">
|
||||||
|
<div class="i4c-modal-window" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header reject-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="times-circle" class="text-red"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<span>Reject Request #{{ rejectingRow.id }}</span>
|
||||||
|
<div class="i4c-modal-header__sub">Provide the reason for rejecting this hold action</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeReject()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="reject-summary">
|
||||||
|
<span
|
||||||
|
>Rejecting: <b>{{ rejectingRow.action }}</b> on Account <b>{{ rejectingRow.account_no }}</b></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Reason Chips -->
|
||||||
|
<div class="preset-box">
|
||||||
|
<div class="preset-title">Quick Preset Reasons:</div>
|
||||||
|
<div class="preset-chips">
|
||||||
|
@for (preset of presetReasons; track preset) {
|
||||||
|
<button type="button" class="preset-chip" (click)="applyPreset(preset)">
|
||||||
|
{{ preset }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rejection Reason Input -->
|
||||||
|
<mat-form-field appearance="outline" class="w-full">
|
||||||
|
<mat-label>Rejection Reason (Required)</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
rows="3"
|
||||||
|
[(ngModel)]="rejectReason"
|
||||||
|
placeholder="Explain why this request is rejected for the maker's audit log..."
|
||||||
|
maxlength="1000"
|
||||||
|
></textarea>
|
||||||
|
<mat-hint align="end">{{ rejectReason.length }}/1000</mat-hint>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeReject()">Cancel</button>
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="warn"
|
||||||
|
class="confirm-reject-btn"
|
||||||
|
(click)="confirmReject()"
|
||||||
|
[disabled]="busyId === rejectingRow.id || !rejectReason.trim()"
|
||||||
|
>
|
||||||
|
<fa-icon
|
||||||
|
[icon]="busyId === rejectingRow.id ? 'sync' : 'times'"
|
||||||
|
[animation]="busyId === rejectingRow.id ? 'spin' : undefined"
|
||||||
|
></fa-icon>
|
||||||
|
Confirm Rejection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.maker-checker-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-main {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acc-link {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-accent {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remarks-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
min-width: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approve-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #10b981;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #059669;
|
||||||
|
box-shadow: 0 4px 10px rgb(16 185 129 / 30%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #fee2e2;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Approve Modal styles */
|
||||||
|
.approve-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approve-action-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #1e40af;
|
||||||
|
background: #eff6ff;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: #fffbeb;
|
||||||
|
border-color: #fde68a;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-green {
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-red {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preset reasons */
|
||||||
|
.preset-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-chip {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-summary {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #475569;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { HOLD_REASON_TYPES, I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checker's queue: release / freeze / unfreeze requests raised on I4C holds. Approving carries the change out on the
|
||||||
|
* account at once; the server refuses an approval by the officer who raised the request.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-approvals',
|
||||||
|
templateUrl: './i4c-approvals.component.html',
|
||||||
|
styleUrls: ['./i4c-approvals.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cApprovalsComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly statuses = [
|
||||||
|
'PENDING',
|
||||||
|
'APPROVED',
|
||||||
|
'REJECTED',
|
||||||
|
'FAILED',
|
||||||
|
''
|
||||||
|
];
|
||||||
|
status = 'PENDING';
|
||||||
|
search = '';
|
||||||
|
action = '';
|
||||||
|
reasonType = '';
|
||||||
|
readonly reasonTypes = HOLD_REASON_TYPES;
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
rows: any[] = [];
|
||||||
|
loading = false;
|
||||||
|
busyId: number | null = null;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
// Modern Approval Popup Modal state
|
||||||
|
approvingRow: any = null;
|
||||||
|
|
||||||
|
// Modern Rejection Popup Modal state
|
||||||
|
rejectingRow: any = null;
|
||||||
|
rejectReason = '';
|
||||||
|
|
||||||
|
readonly presetReasons = [
|
||||||
|
'Insufficient court / IO documentation provided.',
|
||||||
|
'Invalid or unverified reference number.',
|
||||||
|
'Duplicate action request raised.',
|
||||||
|
'Wrong account number specified in order.',
|
||||||
|
'Investigation still in progress; hold must remain.'
|
||||||
|
];
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.holdActions({
|
||||||
|
status: this.status,
|
||||||
|
search: this.search.trim(),
|
||||||
|
action: this.action,
|
||||||
|
reasonType: this.reasonType,
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(s: string): void {
|
||||||
|
this.status = s;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(this.search || this.action || this.reasonType || this.dates.preset !== 'ALL');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the extra filters; the status chip stays as chosen. */
|
||||||
|
clearFilters(): void {
|
||||||
|
this.search = '';
|
||||||
|
this.action = '';
|
||||||
|
this.reasonType = '';
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open modern approval popup modal
|
||||||
|
startApprove(row: any): void {
|
||||||
|
this.approvingRow = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeApprove(): void {
|
||||||
|
this.approvingRow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmApprove(): void {
|
||||||
|
if (!this.approvingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = this.approvingRow;
|
||||||
|
this.busyId = row.id;
|
||||||
|
this.i4c.approveHoldAction(row.id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.approvingRow = null;
|
||||||
|
this.snackBar.open('Approved! The action has been executed immediately on the CBS account.', 'OK', {
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 8000 });
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open modern rejection popup modal
|
||||||
|
startReject(row: any): void {
|
||||||
|
this.rejectingRow = row;
|
||||||
|
this.rejectReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReject(): void {
|
||||||
|
this.rejectingRow = null;
|
||||||
|
this.rejectReason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreset(preset: string): void {
|
||||||
|
this.rejectReason = preset;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmReject(): void {
|
||||||
|
if (!this.rejectingRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.rejectReason.trim()) {
|
||||||
|
this.snackBar.open('A reason is required to reject.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = this.rejectingRow;
|
||||||
|
this.busyId = row.id;
|
||||||
|
this.i4c.rejectHoldAction(row.id, this.rejectReason.trim()).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.rejectingRow = null;
|
||||||
|
this.snackBar.open('Request rejected.', 'OK', { duration: 3000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reasonLabel(value: string): string {
|
||||||
|
return HOLD_REASON_TYPES.find((r) => r.value === value)?.label || value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<!-- Top Filters & Search Toolbar -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
@for (t of types; track t.value) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="type === t.value" (click)="setType(t.value)">
|
||||||
|
<span>{{ t.label }}</span>
|
||||||
|
@if (type === t.value) {
|
||||||
|
<span class="filter-chip-count">{{ total }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<div class="search-box">
|
||||||
|
<fa-icon icon="search" class="search-icon"></fa-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="search"
|
||||||
|
placeholder="Filter by name, code or IFSC prefix..."
|
||||||
|
(keyup.enter)="load(true)"
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
@if (search) {
|
||||||
|
<button class="search-clear-btn" (click)="search = ''; load(true)" aria-label="Clear search">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)" [disabled]="loading">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ifsc-filter">
|
||||||
|
<mat-label>IFSC prefix</mat-label>
|
||||||
|
<mat-select [(ngModel)]="ifsc" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="KNOWN">Prefix known</mat-option>
|
||||||
|
<mat-option value="UNKNOWN">Prefix not known</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Main Directory Card -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card-head">
|
||||||
|
<fa-icon icon="university"></fa-icon>
|
||||||
|
<span>I4C Intermediary & Bank Master Directory</span>
|
||||||
|
<span class="head-hint">Official MHA-I4C 2.0 routing table for beneficiary & payee bank mapping</span>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar-spacer"></span>
|
||||||
|
<span class="entries-counter">{{ total }} routing codes</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="num">Bank Code</th>
|
||||||
|
<th>Institution / Intermediary Name</th>
|
||||||
|
<th>Entity Type</th>
|
||||||
|
<th>IFSC Prefix Pattern</th>
|
||||||
|
<th>Routing Standard</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (b of rows; track b.bank_code) {
|
||||||
|
<tr class="clickable" (click)="openModal(b)">
|
||||||
|
<td class="num">
|
||||||
|
<span class="code-pill font-mono">#{{ b.bank_code }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="bank-name-cell">
|
||||||
|
<div class="entity-avatar entity-avatar--{{ b.entity_type?.toLowerCase() }}">
|
||||||
|
<fa-icon [icon]="getTypeIcon(b.entity_type)"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div class="bank-title-wrap">
|
||||||
|
<span class="bank-name font-semibold">{{ b.bank_name }}</span>
|
||||||
|
<span class="bank-code-sub">Reported as payee_bank_code</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="entity-type-badge entity-type-badge--{{ b.entity_type?.toLowerCase() }}">
|
||||||
|
{{ b.entity_type }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (b.ifsc_prefix) {
|
||||||
|
<button
|
||||||
|
class="copy-chip copy-chip--ifsc"
|
||||||
|
(click)="copyText(b.ifsc_prefix, 'ifsc-' + b.bank_code, $event)"
|
||||||
|
title="Click to copy IFSC prefix"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="copiedKey === 'ifsc-' + b.bank_code ? 'check' : 'copy'"></fa-icon>
|
||||||
|
<span class="font-mono">{{ b.ifsc_prefix }}</span>
|
||||||
|
@if (copiedKey === 'ifsc-' + b.bank_code) {
|
||||||
|
<span class="copy-success-text">Copied!</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted text-xs">Dynamic / Non-IFSC</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="standard-tag"> <fa-icon icon="shield-alt"></fa-icon> MHA-I4C 2.0 </span>
|
||||||
|
</td>
|
||||||
|
<td (click)="$event.stopPropagation()">
|
||||||
|
<button class="table-action-btn" (click)="openModal(b)"><fa-icon icon="eye"></fa-icon> Details</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="university"></fa-icon></div>
|
||||||
|
<div>No banking intermediaries found matching "{{ search }}".</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Bank codes pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: BANK CODE & ROUTING DETAILS MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (selectedBankModal) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeModal()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<!-- Modal Header -->
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header-title">
|
||||||
|
<div
|
||||||
|
class="entity-avatar entity-avatar--lg entity-avatar--{{ selectedBankModal.entity_type?.toLowerCase() }}"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="getTypeIcon(selectedBankModal.entity_type)"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span>{{ selectedBankModal.bank_name }}</span>
|
||||||
|
<span class="entity-type-badge entity-type-badge--{{ selectedBankModal.entity_type?.toLowerCase() }}">
|
||||||
|
{{ selectedBankModal.entity_type }}
|
||||||
|
</span>
|
||||||
|
<span class="code-pill font-mono">Code #{{ selectedBankModal.bank_code }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-header-sub">MHA-I4C Intermediary Specification 2.0 Directory Routing Node</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeModal()" aria-label="Close modal">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Body -->
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<!-- Information Grid -->
|
||||||
|
<div class="details-grid">
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">I4C Root Bank ID</span>
|
||||||
|
<div class="detail-card-val font-mono">
|
||||||
|
<span>{{ selectedBankModal.bank_code }}</span>
|
||||||
|
<button
|
||||||
|
class="copy-chip copy-chip--sm"
|
||||||
|
(click)="copyText(selectedBankModal.bank_code, 'bank-code-modal')"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="copiedKey === 'bank-code-modal' ? 'check' : 'copy'"></fa-icon>
|
||||||
|
{{ copiedKey === 'bank-code-modal' ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="detail-card-sub">Used in root_bankid and payee_bank_code fields</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">IFSC Prefix</span>
|
||||||
|
<div class="detail-card-val font-mono">
|
||||||
|
<span>{{ selectedBankModal.ifsc_prefix || 'Dynamic Routing' }}</span>
|
||||||
|
@if (selectedBankModal.ifsc_prefix) {
|
||||||
|
<button
|
||||||
|
class="copy-chip copy-chip--sm"
|
||||||
|
(click)="copyText(selectedBankModal.ifsc_prefix, 'ifsc-modal')"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="copiedKey === 'ifsc-modal' ? 'check' : 'copy'"></fa-icon>
|
||||||
|
{{ copiedKey === 'ifsc-modal' ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="detail-card-sub">Matches starting characters of Indian Financial System Code</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">Entity Classification</span>
|
||||||
|
<div class="detail-card-val">
|
||||||
|
<span class="entity-type-badge entity-type-badge--{{ selectedBankModal.entity_type?.toLowerCase() }}">
|
||||||
|
{{ selectedBankModal.entity_type }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="detail-card-sub">Classification recognized by Indian Cyber Crime Coordination Centre</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-card">
|
||||||
|
<span class="detail-card-label">Supported Layer Depth</span>
|
||||||
|
<div class="detail-card-val font-semibold text-accent">Layer 1 to Layer 10+</div>
|
||||||
|
<span class="detail-card-sub">Valid for multi-layered money trail tracing</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 6 Integration Preview -->
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box-title">
|
||||||
|
<span>Section 6 Response Payload Example</span>
|
||||||
|
<button class="copy-chip" (click)="copyText(sampleJson(selectedBankModal), 'json-modal')">
|
||||||
|
<fa-icon [icon]="copiedKey === 'json-modal' ? 'check' : 'copy'"></fa-icon>
|
||||||
|
{{ copiedKey === 'json-modal' ? 'Copied!' : 'Copy Schema Snippet' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ sampleJson(selectedBankModal) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Footer -->
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 3px 5px 3px 10px;
|
||||||
|
gap: 6px;
|
||||||
|
box-shadow: 0 1px 3px rgb(15 23 42 / 5%);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgb(37 99 235 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #0f172a;
|
||||||
|
width: 220px;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.i4c-toolbar-spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-counter {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.bank-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-name {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bank-code-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--lg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--bnk {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--wlt {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--mer {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ppi {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #fde68a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-type-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
&--bnk {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--wlt {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
border-color: #ddd6fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--mer {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
border-color: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ppi {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border-color: #fde68a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--ifsc {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
color: #166534;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #dcfce7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--sm {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-success-text {
|
||||||
|
color: #16a34a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.standard-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
&-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-val {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ifsc-filter {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/** The I4C Bank Code Master from the specification: codes the bank reports as root_bankid / payee_bank_code. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-bank-master',
|
||||||
|
templateUrl: './i4c-bank-master.component.html',
|
||||||
|
styleUrls: ['./i4c-bank-master.component.scss'],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cBankMasterComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
|
||||||
|
readonly types = [
|
||||||
|
{ value: '', label: 'All Entities' },
|
||||||
|
{ value: 'BNK', label: 'Commercial Banks' },
|
||||||
|
{ value: 'WLT', label: 'Digital Wallets' },
|
||||||
|
{ value: 'MER', label: 'Payment Merchants' },
|
||||||
|
{ value: 'PPI', label: 'Prepaid Instruments' },
|
||||||
|
{ value: 'CBD', label: 'CBD' },
|
||||||
|
{ value: 'INS', label: 'Insurance' }
|
||||||
|
];
|
||||||
|
|
||||||
|
search = '';
|
||||||
|
type = '';
|
||||||
|
ifsc = '';
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 50;
|
||||||
|
readonly pageSizes = [
|
||||||
|
25,
|
||||||
|
50,
|
||||||
|
100,
|
||||||
|
200
|
||||||
|
];
|
||||||
|
rows: any[] = [];
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
copiedKey = '';
|
||||||
|
selectedBankModal: any = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
setType(t: string): void {
|
||||||
|
if (this.type === t) return;
|
||||||
|
this.type = t;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.bankMaster({
|
||||||
|
search: this.search.trim(),
|
||||||
|
type: this.type,
|
||||||
|
ifsc: this.ifsc,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal(bank: any): void {
|
||||||
|
this.selectedBankModal = bank;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
this.selectedBankModal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText(text: string, key: string, e?: MouseEvent): void {
|
||||||
|
if (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
if (!text) return;
|
||||||
|
navigator.clipboard?.writeText(text);
|
||||||
|
this.copiedKey = key;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedKey === key) {
|
||||||
|
this.copiedKey = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTypeIcon(type: string): string {
|
||||||
|
switch (type?.toUpperCase()) {
|
||||||
|
case 'BNK':
|
||||||
|
return 'university';
|
||||||
|
case 'WLT':
|
||||||
|
return 'mobile-alt';
|
||||||
|
case 'MER':
|
||||||
|
return 'coins';
|
||||||
|
case 'PPI':
|
||||||
|
return 'credit-card';
|
||||||
|
default:
|
||||||
|
return 'building';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sampleJson(b: any): string {
|
||||||
|
if (!b) return '';
|
||||||
|
const sample = {
|
||||||
|
layer_number: 1,
|
||||||
|
transaction_details: {
|
||||||
|
payee_bank_code: b.bank_code,
|
||||||
|
payee_bank_name: b.bank_name,
|
||||||
|
payee_ifsc_prefix: b.ifsc_prefix || 'N/A',
|
||||||
|
entity_category: b.entity_type
|
||||||
|
},
|
||||||
|
mha_routing: {
|
||||||
|
root_bankid: b.bank_code,
|
||||||
|
standard: 'I4C-MHA-2.0'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return JSON.stringify(sample, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<!-- Modern Toolbar -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-toolbar__hint">
|
||||||
|
<fa-icon icon="info-circle" class="hint-ic"></fa-icon>
|
||||||
|
<span
|
||||||
|
>Live NCRP-CFCFRMS Integration: automated complaint processing, targeted account holds & MHA
|
||||||
|
responses.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<button mat-stroked-button class="action-btn" (click)="showConfigModal = true" [disabled]="!data">
|
||||||
|
<fa-icon icon="cog"></fa-icon> Integration Config
|
||||||
|
</button>
|
||||||
|
<button mat-flat-button color="primary" class="action-btn" (click)="load()" [disabled]="loading">
|
||||||
|
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data) {
|
||||||
|
@if (setupGaps.length) {
|
||||||
|
<div class="setup-banner">
|
||||||
|
<div class="setup-banner__icon">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div class="setup-banner__content">
|
||||||
|
<div class="setup-banner__title">Configuration & Production Setup Needed</div>
|
||||||
|
<ul class="setup-banner__list">
|
||||||
|
@for (gap of setupGaps; track gap) {
|
||||||
|
<li>{{ gap }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- KPI Groups: Complaints, Holds, Needs Action -->
|
||||||
|
<div class="kpi-groups">
|
||||||
|
<!-- Section 1: Complaints -->
|
||||||
|
<section class="kpi-group">
|
||||||
|
<div class="kpi-group__head">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<span>Complaints</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-group__row">
|
||||||
|
<a class="modern-kpi" routerLink="../requests">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--blue"><fa-icon icon="clock"></fa-icon></span>
|
||||||
|
<span class="kpi-trend">Last 24h</span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.requestsLast24h }}</span>
|
||||||
|
<span class="modern-kpi__label">Received (24 h)</span>
|
||||||
|
<span class="modern-kpi__sub">{{ data.requestsLast30d }} received in 30 days</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="modern-kpi" routerLink="../requests" [class.modern-kpi--warn]="data.requestsPending > 0">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="tasks"></fa-icon></span>
|
||||||
|
@if (data.requestsPending > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--warn">Needs processing</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.requestsPending }}</span>
|
||||||
|
<span class="modern-kpi__label">Still Processing</span>
|
||||||
|
<span class="modern-kpi__sub">queued / retry queue</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 2: Holds -->
|
||||||
|
<section class="kpi-group">
|
||||||
|
<div class="kpi-group__head">
|
||||||
|
<fa-icon icon="lock"></fa-icon>
|
||||||
|
<span>Holds Register</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-group__row">
|
||||||
|
<a class="modern-kpi" routerLink="../holds" [queryParams]="{ view: 'ACTIVE' }">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--green"><fa-icon icon="shield-alt"></fa-icon></span>
|
||||||
|
<span class="kpi-trend kpi-trend--green">Active</span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.activeHolds }}</span>
|
||||||
|
<span class="modern-kpi__label">Active Account Holds</span>
|
||||||
|
<span class="modern-kpi__sub">disputed amounts restricted</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="modern-kpi" routerLink="../holds" [queryParams]="{ view: 'ACTIVE' }">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--blue"><fa-icon icon="coins"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">₹{{ data.amountHeld | number: '1.2-2' }}</span>
|
||||||
|
<span class="modern-kpi__label">Total Amount Held</span>
|
||||||
|
<span class="modern-kpi__sub">customer account balance</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../holds"
|
||||||
|
[queryParams]="{ view: 'PROSPECTIVE' }"
|
||||||
|
[class.modern-kpi--warn]="data.prospectiveShortfall > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="exclamation-circle"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">₹{{ data.prospectiveShortfall | number: '1.2-2' }}</span>
|
||||||
|
<span class="modern-kpi__label">Prospective Shortfall</span>
|
||||||
|
<span class="modern-kpi__sub">funds pending deposit</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Section 3: Needs Action -->
|
||||||
|
<section class="kpi-group kpi-group--action">
|
||||||
|
<div class="kpi-group__head">
|
||||||
|
<fa-icon icon="bell"></fa-icon>
|
||||||
|
<span>Needs Immediate Action</span>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-group__row">
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../holds"
|
||||||
|
[queryParams]="{ view: 'INTIMATION_DUE' }"
|
||||||
|
[class.modern-kpi--alert]="data.intimationOverdue > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="envelope"></fa-icon></span>
|
||||||
|
@if (data.intimationOverdue > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--alert">Overdue</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.intimationOverdue }}</span>
|
||||||
|
<span class="modern-kpi__label">Intimation Overdue</span>
|
||||||
|
<span class="modern-kpi__sub">RBI notification window</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../holds"
|
||||||
|
[queryParams]="{ view: 'REVIEW_DUE' }"
|
||||||
|
[class.modern-kpi--warn]="data.holdsReviewDue > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="calendar-check"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.holdsReviewDue }}</span>
|
||||||
|
<span class="modern-kpi__label">90-Day Review Due</span>
|
||||||
|
<span class="modern-kpi__sub">due within 15 days</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="modern-kpi" routerLink="../approvals" [class.modern-kpi--warn]="data.pendingApprovals > 0">
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--purple"><fa-icon icon="user-check"></fa-icon></span>
|
||||||
|
@if (data.pendingApprovals > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--warn">Pending Checker</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.pendingApprovals }}</span>
|
||||||
|
<span class="modern-kpi__label">Awaiting Approval</span>
|
||||||
|
<span class="modern-kpi__sub">hold release / freeze</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="modern-kpi"
|
||||||
|
routerLink="../outbox"
|
||||||
|
[queryParams]="{ status: 'FAILED' }"
|
||||||
|
[class.modern-kpi--alert]="data.outboxFailed > 0"
|
||||||
|
>
|
||||||
|
<div class="kpi-top">
|
||||||
|
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="paper-plane"></fa-icon></span>
|
||||||
|
@if (data.outboxFailed > 0) {
|
||||||
|
<span class="kpi-trend kpi-trend--alert">Outbox Fail</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="modern-kpi__value">{{ data.outboxFailed }}</span>
|
||||||
|
<span class="modern-kpi__label">Delivery Failed</span>
|
||||||
|
<span class="modern-kpi__sub">{{ data.outboxPending }} queued to send</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Middle Row: API Performance + Complaints Status -->
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<!-- API Performance 30 Days -->
|
||||||
|
<div class="i4c-card card-perf">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="tachometer-alt"></fa-icon>
|
||||||
|
<span>API Gateway Performance (Last 30 Days)</span>
|
||||||
|
<span class="rbi-badge"><fa-icon icon="shield-alt"></fa-icon> RBI Review Periodic Spec</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Direction</th>
|
||||||
|
<th class="num">Calls</th>
|
||||||
|
<th class="num">Succeeded</th>
|
||||||
|
<th class="num">Failed</th>
|
||||||
|
<th class="num">Success Rate</th>
|
||||||
|
<th class="num">Avg (ms)</th>
|
||||||
|
<th class="num">Max (ms)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of data.apiPerformance30d; track row.direction) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="direction-chip direction-chip--{{ row.direction?.toLowerCase() }}">
|
||||||
|
{{ row.direction === 'IN' ? '↓ Inbound (I4C → Bank)' : '↑ Outbound (Bank → I4C)' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="num font-semibold">{{ row.calls }}</td>
|
||||||
|
<td class="num success-num">{{ row.succeeded }}</td>
|
||||||
|
<td class="num" [class.fail-num]="row.failed > 0">{{ row.failed }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (successRate(row) === null) {
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
} @else {
|
||||||
|
<div class="modern-rate">
|
||||||
|
<div class="modern-rate__bar">
|
||||||
|
<div class="modern-rate__fill" [style.width.%]="successRate(row)"></div>
|
||||||
|
</div>
|
||||||
|
<span class="modern-rate__text">{{ successRate(row) | number: '1.0-1' }}%</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="num latency-pill">{{ row.avg_ms ? row.avg_ms + ' ms' : '-' }}</td>
|
||||||
|
<td class="num latency-pill">{{ row.max_ms ? row.max_ms + ' ms' : '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="i4c-empty">No API calls recorded in the last 30 days.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer-info">
|
||||||
|
<div class="footer-stat">
|
||||||
|
<fa-icon icon="clock"></fa-icon>
|
||||||
|
<span>Average time from complaint arrival to response delivery:</span>
|
||||||
|
<b class="stat-highlight">{{
|
||||||
|
data.avgAckToResponseSeconds === null ? '-' : data.avgAckToResponseSeconds + ' seconds'
|
||||||
|
}}</b>
|
||||||
|
</div>
|
||||||
|
<div class="footer-note">
|
||||||
|
Note: Failed inbound calls include unauthenticated requests and invalid signature payloads.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complaints by Status -->
|
||||||
|
<div class="i4c-card card-status">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="tasks"></fa-icon>
|
||||||
|
<span>Complaints by Status</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-grid">
|
||||||
|
@for (row of data.requestsByStatus; track row.status) {
|
||||||
|
<a class="status-tile" [routerLink]="['../requests']" [queryParams]="{ status: row.status }">
|
||||||
|
<div class="status-tile__left">
|
||||||
|
<span class="i4c-badge" [class]="statusClass(row.status)">{{ row.status }}</span>
|
||||||
|
<span class="status-tile__hint">View complaints</span>
|
||||||
|
</div>
|
||||||
|
<span class="status-tile__count">{{ row.count }}</span>
|
||||||
|
</a>
|
||||||
|
} @empty {
|
||||||
|
<div class="i4c-empty">No complaints received yet.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Row: Findings + Configuration Preview -->
|
||||||
|
<div class="dashboard-grid dashboard-grid--equal">
|
||||||
|
<!-- Reported to I4C -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="list-alt"></fa-icon>
|
||||||
|
<span>Findings Reported to I4C (Section 6)</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Transaction / Hold Type</th>
|
||||||
|
<th class="num">Entries</th>
|
||||||
|
<th class="num">Reported Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of data.findingsByType; track row.txn_type) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ row.txn_type }}</b>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ row.count }}</td>
|
||||||
|
<td class="num highlight-amt">₹{{ row.amount | number: '1.2-2' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="i4c-empty">No findings reported to I4C yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Snapshot -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="cog"></fa-icon>
|
||||||
|
<span>Live Gateway Configuration</span>
|
||||||
|
<button class="config-popup-trigger" (click)="showConfigModal = true">
|
||||||
|
<fa-icon icon="external-link-alt"></fa-icon> Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-grid">
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Gateway Status</div>
|
||||||
|
<div class="config-tile__value">
|
||||||
|
<span class="i4c-badge" [class]="data.configuration.enabled ? 'i4c-badge--green' : 'i4c-badge--red'">
|
||||||
|
{{ data.configuration.enabled ? 'ENABLED' : 'DISABLED' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Tenant</div>
|
||||||
|
<div class="config-tile__value">{{ data.configuration.tenant }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Bank Code (I4C root)</div>
|
||||||
|
<div class="config-tile__value">
|
||||||
|
<b>{{ data.configuration.bankCode || 'Not configured' }}</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Encryption Mode</div>
|
||||||
|
<div class="config-tile__value">
|
||||||
|
<span class="crypto-chip">{{ data.configuration.cryptoMode }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile config-tile--full">
|
||||||
|
<div class="config-tile__label">I4C Outbound API Base URL</div>
|
||||||
|
<div class="config-tile__value url-val">
|
||||||
|
<span>{{ data.configuration.outboundBaseUrl || 'Not configured' }}</span>
|
||||||
|
@if (data.configuration.outboundBaseUrl) {
|
||||||
|
<button class="copy-btn" (click)="copyText(data.configuration.outboundBaseUrl, 'url')">
|
||||||
|
{{ copiedKey === 'url' ? 'Copied!' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Review Window</div>
|
||||||
|
<div class="config-tile__value">{{ data.configuration.holdReviewDays }} Days (90-day cycle)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-tile">
|
||||||
|
<div class="config-tile__label">Match Window</div>
|
||||||
|
<div class="config-tile__value">± {{ data.configuration.matchDateWindowDays }} Days</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: CONFIGURATION DETAILS MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (showConfigModal && data?.configuration) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="showConfigModal = false">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="cog"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<span>I4C-MHA Integration Configuration</span>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
NCRP-CFCFRMS Specification 2.0 Security & Connection Parameters
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="showConfigModal = false" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Integration Enabled</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="i4c-badge" [class]="data.configuration.enabled ? 'i4c-badge--green' : 'i4c-badge--red'">
|
||||||
|
{{ data.configuration.enabled ? 'ACTIVE (Online)' : 'DISABLED' }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Tenant ID</dt>
|
||||||
|
<dd>{{ data.configuration.tenant }}</dd>
|
||||||
|
|
||||||
|
<dt>Bank Code (I4C ID)</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ data.configuration.bankCode }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Inbound Credentials</dt>
|
||||||
|
<dd>
|
||||||
|
{{ data.configuration.inboundCredentialsConfigured ? 'Configured' : 'Not configured' }}
|
||||||
|
@if (data.configuration.inboundPasswordHashed) {
|
||||||
|
<span class="badge-mini">bcrypt hash active</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Allowed I4C IPs</dt>
|
||||||
|
<dd>{{ data.configuration.allowedIpCount || 'Any / All (UAT mode)' }}</dd>
|
||||||
|
|
||||||
|
<dt>Payload Encryption</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="crypto-chip">{{ data.configuration.cryptoMode }}</span>
|
||||||
|
@if (data.configuration.cryptoPassphraseConfigured) {
|
||||||
|
<span class="badge-mini">passphrase active</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Outbound Base URL</dt>
|
||||||
|
<dd class="font-mono">{{ data.configuration.outboundBaseUrl || 'Not set' }}</dd>
|
||||||
|
|
||||||
|
<dt>Automatic Hold Review</dt>
|
||||||
|
<dd>{{ data.configuration.holdReviewDays }} days</dd>
|
||||||
|
|
||||||
|
<dt>Transaction Match Window</dt>
|
||||||
|
<dd>± {{ data.configuration.matchDateWindowDays }} days</dd>
|
||||||
|
|
||||||
|
<dt>Simulator Status</dt>
|
||||||
|
<dd>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class]="data.configuration.simulatorEnabled ? 'i4c-badge--amber' : 'i4c-badge--green'"
|
||||||
|
>
|
||||||
|
{{ data.configuration.simulatorEnabled ? 'SIMULATOR ACTIVE (UAT)' : 'PRODUCTION' }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="i4c-note">
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
<span
|
||||||
|
>All passwords, encryption keys, and tokens are stored in the server keystore and never revealed on the
|
||||||
|
UI.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="showConfigModal = false">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
/* Setup Banner */
|
||||||
|
.setup-banner {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 10px rgb(245 158 11 / 8%);
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #d97706;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI Groups */
|
||||||
|
.kpi-groups {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 3fr 4fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1280px) {
|
||||||
|
.kpi-groups {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
||||||
|
.kpi-group--action {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.kpi-groups {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: minmax(0, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 580px) {
|
||||||
|
.kpi-group__row {
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Fintech KPI Tile */
|
||||||
|
.modern-kpi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
box-shadow: 0 3px 10px rgb(15 23 42 / 3%);
|
||||||
|
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #93c5fd;
|
||||||
|
box-shadow: 0 8px 16px -3px rgb(37 99 235 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-icon-circle {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--amber {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--purple {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-trend {
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--alert {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: #fffdf5;
|
||||||
|
|
||||||
|
.modern-kpi__value {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--alert {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fffbfb;
|
||||||
|
|
||||||
|
.modern-kpi__value {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Grid */
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
|
||||||
|
&--equal {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1024px) {
|
||||||
|
.dashboard-grid,
|
||||||
|
.dashboard-grid--equal {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API Performance */
|
||||||
|
.rbi-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e40af;
|
||||||
|
background: #dbeafe;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--in {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--out {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-num {
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fail-num {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-rate {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
&__bar {
|
||||||
|
width: 60px;
|
||||||
|
height: 7px;
|
||||||
|
background: #fee2e2;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #10b981, #059669);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.latency-pill {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer-info {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
.footer-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-highlight {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-note {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Tile List */
|
||||||
|
.status-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
background: #f8fafc;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 4px 10px rgb(15 23 42 / 5%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Configuration Grid */
|
||||||
|
.config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-val {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-popup-trigger {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.crypto-chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e40af;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-mini {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight-amt {
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DecimalPipe } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration health at a glance, and the API performance figures the RBI letter (05 Dec 2025, para 4) asks to be
|
||||||
|
* reviewed periodically: calls, failures and response times in each direction.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-dashboard',
|
||||||
|
templateUrl: './i4c-dashboard.component.html',
|
||||||
|
styleUrls: ['./i4c-dashboard.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DecimalPipe,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cDashboardComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
|
||||||
|
data: any = null;
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
showConfigModal = false;
|
||||||
|
copiedKey = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c.dashboard().subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
this.data = d;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText(text: string, key: string): void {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.copiedKey = key;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedKey === key) {
|
||||||
|
this.copiedKey = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'PROCESSED':
|
||||||
|
return 'i4c-badge--green';
|
||||||
|
case 'RETRY':
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
case 'RECEIVED':
|
||||||
|
case 'PROCESSING':
|
||||||
|
return 'i4c-badge--amber';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Success rate of calls in one direction, as a percentage; null when there were none. */
|
||||||
|
successRate(row: any): number | null {
|
||||||
|
const calls = Number(row?.calls || 0);
|
||||||
|
return calls === 0 ? null : (Number(row?.succeeded || 0) * 100) / calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What still stands between this bank and live traffic, worked out from the configuration. */
|
||||||
|
get setupGaps(): string[] {
|
||||||
|
const c = this.data?.configuration;
|
||||||
|
if (!c) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const gaps: string[] = [];
|
||||||
|
if (!c.enabled) {
|
||||||
|
gaps.push('Integration is switched off (fineract.i4c.enabled=false): I4C calls are refused with 503.');
|
||||||
|
}
|
||||||
|
if (!c.inboundCredentialsConfigured) {
|
||||||
|
gaps.push('No inbound username/password set for I4C to obtain a token.');
|
||||||
|
} else if (!c.inboundPasswordHashed) {
|
||||||
|
gaps.push('Inbound password is configured in plain text; set the bcrypt hash for production.');
|
||||||
|
}
|
||||||
|
if (!c.allowedIpCount) {
|
||||||
|
gaps.push('No I4C source IP allowlist: any address may call (UAT only).');
|
||||||
|
}
|
||||||
|
if (c.cryptoMode !== 'NONE' && !c.cryptoPassphraseConfigured) {
|
||||||
|
gaps.push('Encryption mode is ' + c.cryptoMode + ' but no passphrase is configured.');
|
||||||
|
}
|
||||||
|
if (!c.outboundConfigured) {
|
||||||
|
gaps.push('I4C response URL is not configured: replies wait in the outbox.');
|
||||||
|
}
|
||||||
|
if (!c.bankCode) {
|
||||||
|
gaps.push("This bank's I4C bank code is not set (fineract.i4c.bank-code); root_bankid is sent as 0.");
|
||||||
|
}
|
||||||
|
if (c.simulatorEnabled) {
|
||||||
|
gaps.push('The local I4C simulator is enabled; switch it off in production.');
|
||||||
|
}
|
||||||
|
return gaps;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div class="date-range" role="group" [attr.aria-label]="label + ' filter'">
|
||||||
|
<span class="date-range__label"><fa-icon icon="calendar-alt"></fa-icon> {{ label }}</span>
|
||||||
|
<div class="date-range__presets">
|
||||||
|
@for (p of presets; track p.value) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="date-chip"
|
||||||
|
[class.date-chip--active]="range.preset === p.value"
|
||||||
|
[attr.aria-pressed]="range.preset === p.value"
|
||||||
|
(click)="pick(p.value)"
|
||||||
|
>
|
||||||
|
{{ p.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (range.preset !== 'ALL') {
|
||||||
|
<div class="date-range__custom">
|
||||||
|
<label>
|
||||||
|
From
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
[max]="range.toDate || today"
|
||||||
|
[(ngModel)]="range.fromDate"
|
||||||
|
(change)="customChanged()"
|
||||||
|
[attr.aria-label]="label + ' from'"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
To
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
[min]="range.fromDate"
|
||||||
|
[max]="today"
|
||||||
|
[(ngModel)]="range.toDate"
|
||||||
|
(change)="customChanged()"
|
||||||
|
[attr.aria-label]="label + ' to'"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__presets {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__custom {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0f172a;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 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, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
|
export type I4cDatePreset = 'ALL' | 'TODAY' | 'YESTERDAY' | 'WEEK' | 'MONTH' | 'CUSTOM';
|
||||||
|
|
||||||
|
/** A date range as the list APIs take it: yyyy-MM-dd, both days included, empty for "no limit". */
|
||||||
|
export interface I4cDateRange {
|
||||||
|
preset: I4cDatePreset;
|
||||||
|
fromDate: string;
|
||||||
|
toDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ALL_DATES: I4cDateRange = { preset: 'ALL', fromDate: '', toDate: '' };
|
||||||
|
|
||||||
|
/** yyyy-MM-dd in the browser's own time zone (toISOString would shift to UTC). */
|
||||||
|
function iso(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The days a preset stands for, worked out from today. */
|
||||||
|
export function presetRange(preset: I4cDatePreset): I4cDateRange {
|
||||||
|
const today = new Date();
|
||||||
|
const day = (offset: number) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset);
|
||||||
|
switch (preset) {
|
||||||
|
case 'TODAY':
|
||||||
|
return { preset, fromDate: iso(today), toDate: iso(today) };
|
||||||
|
case 'YESTERDAY':
|
||||||
|
return { preset, fromDate: iso(day(-1)), toDate: iso(day(-1)) };
|
||||||
|
case 'WEEK': {
|
||||||
|
// The week starts on Monday.
|
||||||
|
const sinceMonday = (today.getDay() + 6) % 7;
|
||||||
|
return { preset, fromDate: iso(day(-sinceMonday)), toDate: iso(today) };
|
||||||
|
}
|
||||||
|
case 'MONTH':
|
||||||
|
return { preset, fromDate: iso(new Date(today.getFullYear(), today.getMonth(), 1)), toDate: iso(today) };
|
||||||
|
default:
|
||||||
|
return { ...ALL_DATES };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date filter shared by the I4C lists: All, Today, Yesterday, This Week, This Month, or a custom From / To. Emits the
|
||||||
|
* new range whenever it changes; the page then reloads from page one.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-date-range',
|
||||||
|
templateUrl: './i4c-date-range.component.html',
|
||||||
|
styleUrls: ['./i4c-date-range.component.scss'],
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cDateRangeComponent {
|
||||||
|
@Input() label = 'Date';
|
||||||
|
@Input() range: I4cDateRange = { ...ALL_DATES };
|
||||||
|
@Output() rangeChange = new EventEmitter<I4cDateRange>();
|
||||||
|
|
||||||
|
readonly presets: { value: I4cDatePreset; label: string }[] = [
|
||||||
|
{ value: 'ALL', label: 'All' },
|
||||||
|
{ value: 'TODAY', label: 'Today' },
|
||||||
|
{ value: 'YESTERDAY', label: 'Yesterday' },
|
||||||
|
{ value: 'WEEK', label: 'This Week' },
|
||||||
|
{ value: 'MONTH', label: 'This Month' },
|
||||||
|
{ value: 'CUSTOM', label: 'Custom' }
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly today = iso(new Date());
|
||||||
|
|
||||||
|
pick(preset: I4cDatePreset): void {
|
||||||
|
if (preset === 'CUSTOM') {
|
||||||
|
// Keep the days already shown so the officer can adjust them.
|
||||||
|
this.range = { ...this.range, preset };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emit(presetRange(preset));
|
||||||
|
}
|
||||||
|
|
||||||
|
customChanged(): void {
|
||||||
|
const { fromDate, toDate } = this.range;
|
||||||
|
if (fromDate && toDate && toDate < fromDate) {
|
||||||
|
// A reversed range would match nothing; read it the way it was meant.
|
||||||
|
this.range = { preset: 'CUSTOM', fromDate: toDate, toDate: fromDate };
|
||||||
|
}
|
||||||
|
this.emit({ ...this.range, preset: 'CUSTOM' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(range: I4cDateRange): void {
|
||||||
|
this.range = range;
|
||||||
|
this.rangeChange.emit(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<!--
|
||||||
|
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="docs">
|
||||||
|
@if (missing.length) {
|
||||||
|
<div class="docs__missing">
|
||||||
|
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||||
|
Still needed:
|
||||||
|
@for (m of missing; track m; let last = $last) {
|
||||||
|
<b>{{ label(m) }}</b
|
||||||
|
>{{ last ? '' : ', ' }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>File</th>
|
||||||
|
<th>Remarks</th>
|
||||||
|
<th>Uploaded</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (d of rows; track d.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ label(d.doc_type) }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="docs__name">{{ d.file_name }}</span>
|
||||||
|
<div class="docs__sub" [title]="'SHA-256 ' + d.sha256">
|
||||||
|
{{ size(d.size_bytes) }} · {{ d.sha256.slice(0, 12) }}…
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ d.remarks || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{{ d.uploaded_at | date: 'dd MMM yyyy, HH:mm' }}
|
||||||
|
<div class="docs__sub">{{ d.uploaded_by }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button mat-stroked-button type="button" (click)="download(d)">
|
||||||
|
<fa-icon icon="download"></fa-icon> Download
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="i4c-empty">No documents attached yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if (canUpload) {
|
||||||
|
<div class="docs__upload">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Document type</mat-label>
|
||||||
|
<mat-select [(ngModel)]="docType">
|
||||||
|
@for (t of types; track t.value) {
|
||||||
|
<mat-option [value]="t.value">{{ t.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="docs__remarks">
|
||||||
|
<mat-label>Remarks (order no., date…)</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="500" />
|
||||||
|
</mat-form-field>
|
||||||
|
<label class="docs__file">
|
||||||
|
<input #fileInput type="file" accept="application/pdf,image/jpeg,image/png" (change)="pick($event)" />
|
||||||
|
</label>
|
||||||
|
<button mat-flat-button color="primary" type="button" (click)="upload(fileInput)" [disabled]="uploading || !file">
|
||||||
|
<fa-icon icon="upload"></fa-icon> {{ uploading ? 'Uploading…' : 'Attach' }}
|
||||||
|
</button>
|
||||||
|
<span class="docs__sub">PDF, JPEG or PNG, up to 5 MB. Stored with a SHA-256; never changed once attached.</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
&__missing {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 12.5px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__upload {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
mat-form-field {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__remarks {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__file input {
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* 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, EventEmitter, Input, OnChanges, Output, inject } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { DOCUMENT_TYPES, I4cDocument, I4cDocumentOwner, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
|
||||||
|
|
||||||
|
const MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
const ACCEPTED = [
|
||||||
|
'application/pdf',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents behind a hold, grievance or restoration: list, download, and upload (PDF / JPEG / PNG up to 5 MB). Shows
|
||||||
|
* which of the {@link required} types are still missing.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-documents',
|
||||||
|
templateUrl: './i4c-documents.component.html',
|
||||||
|
styleUrls: ['./i4c-documents.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cDocumentsComponent implements OnChanges {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
@Input({ required: true }) ownerType!: I4cDocumentOwner;
|
||||||
|
@Input({ required: true }) ownerId!: number;
|
||||||
|
/** Document types this record needs; missing ones are flagged. */
|
||||||
|
@Input() required: string[] = [];
|
||||||
|
/** Types offered first in the picker. */
|
||||||
|
@Input() suggested: string[] = [];
|
||||||
|
@Input() canUpload = true;
|
||||||
|
/** Documents already loaded with the record, to avoid a second call. */
|
||||||
|
@Input() documents: I4cDocument[] | null = null;
|
||||||
|
@Output() changed = new EventEmitter<void>();
|
||||||
|
|
||||||
|
rows: I4cDocument[] = [];
|
||||||
|
docType = '';
|
||||||
|
remarks = '';
|
||||||
|
file: File | null = null;
|
||||||
|
uploading = false;
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
if (this.documents) {
|
||||||
|
this.rows = this.documents;
|
||||||
|
} else {
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
if (!this.docType) {
|
||||||
|
this.docType = this.suggested[0] || this.missing[0] || 'OTHER';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get types(): { value: string; label: string }[] {
|
||||||
|
const first = DOCUMENT_TYPES.filter((t) => this.suggested.includes(t.value) || this.required.includes(t.value));
|
||||||
|
return [
|
||||||
|
...first,
|
||||||
|
...DOCUMENT_TYPES.filter((t) => !first.includes(t))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
get missing(): string[] {
|
||||||
|
return this.required.filter((t) => !this.rows.some((d) => d.doc_type === t));
|
||||||
|
}
|
||||||
|
|
||||||
|
label(type: string): string {
|
||||||
|
return DOCUMENT_TYPES.find((t) => t.value === type)?.label || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
if (!this.ownerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.i4c.documents(this.ownerType, this.ownerId).subscribe({
|
||||||
|
next: (rows) => (this.rows = rows),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const f = input.files?.[0] || null;
|
||||||
|
if (f && !ACCEPTED.includes(f.type)) {
|
||||||
|
this.snackBar.open('Only PDF, JPEG and PNG files can be attached.', 'Close', { duration: 5000 });
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (f && f.size > MAX_BYTES) {
|
||||||
|
this.snackBar.open('A document can be at most 5 MB.', 'Close', { duration: 5000 });
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.file = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
upload(input: HTMLInputElement): void {
|
||||||
|
if (!this.file || !this.docType) {
|
||||||
|
this.snackBar.open('Choose the document type and the file.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = this.file;
|
||||||
|
this.uploading = true;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = String(reader.result || '');
|
||||||
|
const content = dataUrl.substring(dataUrl.indexOf(',') + 1);
|
||||||
|
this.i4c
|
||||||
|
.uploadDocument({
|
||||||
|
ownerType: this.ownerType,
|
||||||
|
ownerId: this.ownerId,
|
||||||
|
docType: this.docType,
|
||||||
|
fileName: file.name,
|
||||||
|
contentType: file.type,
|
||||||
|
content,
|
||||||
|
remarks: this.remarks.trim() || undefined
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.uploading = false;
|
||||||
|
this.file = null;
|
||||||
|
this.remarks = '';
|
||||||
|
input.value = '';
|
||||||
|
this.snackBar.open(this.label(this.docType) + ' attached.', 'OK', { duration: 3000 });
|
||||||
|
this.documents = null;
|
||||||
|
this.reload();
|
||||||
|
this.changed.emit();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.uploading = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
this.uploading = false;
|
||||||
|
this.snackBar.open('The file could not be read.', 'Close', { duration: 5000 });
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
download(d: I4cDocument): void {
|
||||||
|
this.i4c.downloadDocument(d.id).subscribe({
|
||||||
|
next: (blob) => saveBlob(blob, d.file_name),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
size(bytes: number): string {
|
||||||
|
return bytes < 1024 * 1024 ? Math.ceil(bytes / 1024) + ' KB' : (bytes / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="stage === 'OPEN'" (click)="setStage('OPEN')">
|
||||||
|
<span>All open</span>
|
||||||
|
</button>
|
||||||
|
@for (s of stages; track s.value) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="stage === s.value" (click)="setStage(s.value)">
|
||||||
|
<span>{{ s.label }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="stage === ''" (click)="setStage('')">
|
||||||
|
<span>All</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<span class="count-tag">{{ total }} grievances</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||||
|
<mat-label>GR id / account / ack no. / customer</mat-label>
|
||||||
|
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" (blur)="load(true)" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Outcome</mat-label>
|
||||||
|
<mat-select [(ngModel)]="status" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="OPEN">Pending decision</mat-option>
|
||||||
|
<mat-option value="ALLOWED">Allowed (order given)</mat-option>
|
||||||
|
<mat-option value="REJECTED">Rejected</mat-option>
|
||||||
|
<mat-option value="COURT">Left to the court</mat-option>
|
||||||
|
<mat-option value="WITHDRAWN">Withdrawn</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-checkbox [(ngModel)]="overdueOnly" (change)="load(true)">Past deadline only</mat-checkbox>
|
||||||
|
<mifosx-i4c-date-range label="Raised" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon> <span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="balance-scale"></fa-icon>
|
||||||
|
<span>GRM Grievances (NCRP Grievance Redressal Module)</span>
|
||||||
|
<span class="head-hint"
|
||||||
|
>Raise a grievance from the hold (Holds Register → hold → GRM / MRM). Click a row to work on it.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Raised</th>
|
||||||
|
<th>GR id</th>
|
||||||
|
<th>Account / Customer</th>
|
||||||
|
<th>Customer asks</th>
|
||||||
|
<th class="num">Held (₹)</th>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th>Deadline</th>
|
||||||
|
<th>Outcome</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (g of rows; track g.id) {
|
||||||
|
<tr class="clickable" (click)="open(g.id)">
|
||||||
|
<td>
|
||||||
|
{{ g.created_at | date: 'dd MMM yyyy' }}
|
||||||
|
<div class="sub">{{ g.created_by }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ g.grievance_no || 'Draft #' + g.id }}</b>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ g.account_no }}</b>
|
||||||
|
<div class="sub">{{ g.client_name }} · Ack {{ g.acknowledgement_no }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ requestLabel(g.customer_request) }}</td>
|
||||||
|
<td class="num">{{ g.amount_held | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ stageLabel(g.stage) }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (g.compliance_due_at && g.stage !== 'CLOSED') {
|
||||||
|
<span class="deadline" [class.deadline--late]="g.compliance_overdue">
|
||||||
|
Carry out by {{ g.compliance_due_at | date: 'dd MMM, HH:mm' }}
|
||||||
|
</span>
|
||||||
|
} @else if (g.due_at) {
|
||||||
|
<span class="deadline" [class.deadline--late]="g.overdue">{{ g.due_at | date: 'dd MMM yyyy' }}</span>
|
||||||
|
} @else {
|
||||||
|
-
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="g.status === 'ALLOWED'"
|
||||||
|
[class.i4c-badge--red]="g.status === 'REJECTED' || g.status === 'COURT'"
|
||||||
|
[class.i4c-badge--amber]="g.status === 'OPEN'"
|
||||||
|
>{{ g.status }}</span
|
||||||
|
>
|
||||||
|
@if (g.decision) {
|
||||||
|
<div class="sub">{{ g.decision_level }}: {{ decisionLabel(g.decision) }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="i4c-empty">No grievances match these filters.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Grievance pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selected; as g) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="close()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="balance-scale"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span>{{ g.grievance_no || 'Draft grievance #' + g.id }}</span>
|
||||||
|
<span class="i4c-badge">{{ stageLabel(g.stage) }}</span>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="g.status === 'ALLOWED'"
|
||||||
|
[class.i4c-badge--red]="g.status === 'REJECTED' || g.status === 'COURT'"
|
||||||
|
>{{ g.status }}</span
|
||||||
|
>
|
||||||
|
@if (g.overdue || g.compliance_overdue) {
|
||||||
|
<span class="i4c-badge i4c-badge--red">PAST DEADLINE</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
Account <b>{{ g.account_no }}</b> ({{ g.client_name }}) · Ack {{ g.acknowledgement_no }} · Held ₹{{
|
||||||
|
g.amount_held | number: '1.2-2'
|
||||||
|
}}{{ g.debit_frozen ? ' · debits frozen' : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__actions">
|
||||||
|
<button class="modal-close-btn" (click)="close()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-tabs">
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'workflow'" (click)="tab = 'workflow'">
|
||||||
|
<fa-icon icon="tasks"></fa-icon> Next step
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'overview'" (click)="tab = 'overview'">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> Details & EDD
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'documents'" (click)="tab = 'documents'">
|
||||||
|
<fa-icon icon="paperclip"></fa-icon> Documents ({{ g.documents?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'history'" (click)="tab = 'history'">
|
||||||
|
<fa-icon icon="history"></fa-icon> History ({{ g.events?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="flow">
|
||||||
|
@for (f of flow(g); track f.value; let last = $last) {
|
||||||
|
<span
|
||||||
|
class="flow__stage"
|
||||||
|
[class.flow__stage--done]="f.state === 'done'"
|
||||||
|
[class.flow__stage--now]="f.state === 'now'"
|
||||||
|
>{{ f.label }}</span
|
||||||
|
>
|
||||||
|
@if (!last) {
|
||||||
|
<span class="flow__arrow">›</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (tab === 'workflow') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@if (g.stage === 'DRAFT') {
|
||||||
|
@if (!g.edd_done) {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">1. Enhanced due diligence (before raising on GRM)</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-checkbox [(ngModel)]="edd.kycVerified">KYC re-verified</mat-checkbox>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Risk</mat-label>
|
||||||
|
<mat-select [(ngModel)]="edd.risk">
|
||||||
|
<mat-option value="LOW">Low</mat-option>
|
||||||
|
<mat-option value="MEDIUM">Medium</mat-option>
|
||||||
|
<mat-option value="HIGH">High</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Bank's view</mat-label>
|
||||||
|
<mat-select [(ngModel)]="edd.recommendation">
|
||||||
|
<mat-option value="SUPPORT">Supports the request</mat-option>
|
||||||
|
<mat-option value="NOT_SUPPORT">Does not support it</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Source of the disputed credit, as the customer explains it *</mat-label>
|
||||||
|
<textarea matInput rows="2" [(ngModel)]="edd.sourceOfFunds" maxlength="2000"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Customer's statement / notes</mat-label>
|
||||||
|
<textarea matInput rows="2" [(ngModel)]="edd.customerStatement" maxlength="2000"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="saveEdd()" [disabled]="busy">
|
||||||
|
<fa-icon icon="user-shield"></fa-icon> Save due diligence
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">2. Raise it on GRM, then record the GR id</div>
|
||||||
|
<div class="sub">
|
||||||
|
GRM's entry form needs the KYC document, the customer's application, a photograph and the bank
|
||||||
|
statement. Attach them on the Documents tab (the statement can be downloaded from the hold).
|
||||||
|
</div>
|
||||||
|
@if (missingDocs(g).length) {
|
||||||
|
<div class="i4c-note">Still to attach: {{ missingDocs(g).join(', ') }}</div>
|
||||||
|
}
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>GR id from GRM *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="grievanceNo" maxlength="40" placeholder="GR003260316001" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="primary"
|
||||||
|
(click)="submit()"
|
||||||
|
[disabled]="busy || !grievanceNo.trim()"
|
||||||
|
>
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> Mark raised on GRM
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.stage === 'NODAL_REVIEW') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Bank GRM nodal officer's decision</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Remarks (required to reject)</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="nodal(true)" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Approved on GRM (to IO)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
color="warn"
|
||||||
|
(click)="nodal(false)"
|
||||||
|
[disabled]="busy || !remarks.trim()"
|
||||||
|
>
|
||||||
|
<fa-icon icon="times"></fa-icon> Rejected by nodal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (withOfficer && g.status === 'OPEN') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
Decision of the {{ level }}
|
||||||
|
@if (g.due_at) {
|
||||||
|
<span class="deadline" [class.deadline--late]="isLate(g.due_at)">
|
||||||
|
· due {{ g.due_at | date: 'dd MMM yyyy' }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Decision on GRM</mat-label>
|
||||||
|
<mat-select [(ngModel)]="decision.decision">
|
||||||
|
@for (d of decisions; track d.value) {
|
||||||
|
<mat-option [value]="d.value">{{ d.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (decision.decision === 'PARTIAL_HOLD') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Amount to keep on hold (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="decision.keepAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
@if (decision.decision !== 'SEEK_INFO' && decision.decision !== 'REJECTED_CONTINUE') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Order no. *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="decision.orderRef" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Order / decision date</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="decision.orderDate" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label
|
||||||
|
>Remarks{{ decision.decision === 'REJECTED_CONTINUE' ? ' (reason given) *' : '' }}</mat-label
|
||||||
|
>
|
||||||
|
<input matInput [(ngModel)]="decision.remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
@if (decision.decision !== 'SEEK_INFO' && decision.decision !== 'REJECTED_CONTINUE') {
|
||||||
|
<div class="sub">
|
||||||
|
Attach the signed and stamped order copy (GRM order or IO order) on the Documents tab first.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="decide()" [disabled]="busy">
|
||||||
|
<fa-icon icon="gavel"></fa-icon> Record decision
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Something else happened on GRM</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Event</mat-label>
|
||||||
|
<mat-select [(ngModel)]="noteType">
|
||||||
|
@for (n of noteEvents; track n.value) {
|
||||||
|
<mat-option [value]="n.value">{{ n.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Remarks</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-stroked-button (click)="note()" [disabled]="busy">
|
||||||
|
<fa-icon icon="plus"></fa-icon> Add to history
|
||||||
|
</button>
|
||||||
|
@if (g.stage === 'WITH_IO') {
|
||||||
|
<button mat-stroked-button (click)="investigation()" [disabled]="busy">
|
||||||
|
<fa-icon icon="search"></fa-icon> IO is investigating (+15 days)
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.status === 'REJECTED' && g.stage !== 'CLOSED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Rejected by the {{ g.decision_level }}: {{ g.decision_remarks }}</div>
|
||||||
|
<div class="sub">
|
||||||
|
If the customer is not satisfied, the branch raises a review: IO → District GRO → State GRO (15
|
||||||
|
days each); after the State GRO the customer may approach the jurisdictional court.
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Remarks</mat-label>
|
||||||
|
<input matInput [(ngModel)]="remarks" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="escalate()" [disabled]="busy">
|
||||||
|
<fa-icon icon="arrow-up"></fa-icon> {{ escalateLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.status === 'ALLOWED' && g.stage !== 'CLOSED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
Order: {{ decisionLabel(g.decision) }}
|
||||||
|
@if (g.decision_keep_amount !== null) {
|
||||||
|
(keep ₹{{ g.decision_keep_amount | number: '1.2-2' }})
|
||||||
|
}
|
||||||
|
· {{ g.decision_order_ref }}
|
||||||
|
</div>
|
||||||
|
<div class="sub">
|
||||||
|
Carry it out on the account by
|
||||||
|
<span class="deadline" [class.deadline--late]="g.compliance_overdue">{{
|
||||||
|
g.compliance_due_at | date: 'dd MMM yyyy, HH:mm'
|
||||||
|
}}</span
|
||||||
|
>. The change goes to a second officer for approval; the grievance closes when it is approved.
|
||||||
|
</div>
|
||||||
|
@if (g.actionRequest?.length) {
|
||||||
|
<div class="sub">
|
||||||
|
Request #{{ g.actionRequest[0].id }} ({{ g.actionRequest[0].action }}) is
|
||||||
|
<b>{{ g.actionRequest[0].status }}</b>
|
||||||
|
{{ g.actionRequest[0].failure_message || g.actionRequest[0].rejection_reason || '' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (
|
||||||
|
!g.actionRequest?.length ||
|
||||||
|
g.actionRequest[0].status === 'REJECTED' ||
|
||||||
|
g.actionRequest[0].status === 'FAILED'
|
||||||
|
) {
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="warn" (click)="raiseAction()" [disabled]="busy">
|
||||||
|
<fa-icon icon="hand-paper"></fa-icon> Raise the account change for approval
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (g.stage === 'CLOSED') {
|
||||||
|
<div class="i4c-note">This grievance is closed ({{ g.status }}).</div>
|
||||||
|
} @else {
|
||||||
|
<div class="step">
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-button color="warn" (click)="withdraw()" [disabled]="busy">
|
||||||
|
Customer withdrew the grievance
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'overview') {
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="user"></fa-icon> Customer's request</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Asks to</dt>
|
||||||
|
<dd>{{ requestLabel(g.customer_request) }}</dd>
|
||||||
|
<dt>Reason given</dt>
|
||||||
|
<dd class="wrap">{{ g.customer_reason || '-' }}</dd>
|
||||||
|
<dt>Opened</dt>
|
||||||
|
<dd>{{ g.created_at | date: 'dd MMM yyyy, HH:mm' }} by {{ g.created_by }}</dd>
|
||||||
|
<dt>Decision</dt>
|
||||||
|
<dd>
|
||||||
|
{{ g.decision ? g.decision_level + ': ' + decisionLabel(g.decision) : '-' }}
|
||||||
|
@if (g.decision_order_ref) {
|
||||||
|
<div class="sub">
|
||||||
|
Order {{ g.decision_order_ref }} {{ g.decision_date | date: 'dd MMM yyyy' }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="user-shield"></fa-icon> Enhanced due diligence</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@if (g.edd_done) {
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>KYC re-verified</dt>
|
||||||
|
<dd>{{ g.edd_kyc_verified ? 'Yes' : 'No' }}</dd>
|
||||||
|
<dt>Source of funds</dt>
|
||||||
|
<dd class="wrap">{{ g.edd_source_of_funds }}</dd>
|
||||||
|
<dt>Statement</dt>
|
||||||
|
<dd class="wrap">{{ g.edd_customer_statement || '-' }}</dd>
|
||||||
|
<dt>Risk / view</dt>
|
||||||
|
<dd>
|
||||||
|
{{ g.edd_risk }} ·
|
||||||
|
{{ g.edd_recommendation === 'SUPPORT' ? 'Bank supports' : 'Bank does not support' }}
|
||||||
|
</dd>
|
||||||
|
<dt>By</dt>
|
||||||
|
<dd>{{ g.edd_by }}, {{ g.edd_at | date: 'dd MMM yyyy, HH:mm' }}</dd>
|
||||||
|
</dl>
|
||||||
|
} @else {
|
||||||
|
<div class="i4c-note">Not done yet.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'documents') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<mifosx-i4c-documents
|
||||||
|
ownerType="GRIEVANCE"
|
||||||
|
[ownerId]="g.id"
|
||||||
|
[required]="g.requiredDocuments || []"
|
||||||
|
[suggested]="['GRM_ORDER', 'IO_ORDER', 'EDD_REPORT']"
|
||||||
|
[documents]="g.documents"
|
||||||
|
[canUpload]="g.stage !== 'CLOSED'"
|
||||||
|
(changed)="open(g.id)"
|
||||||
|
></mifosx-i4c-documents>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'history') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th>Remarks</th>
|
||||||
|
<th>By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (e of g.events; track e.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ e.created_at | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ e.event_type }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ stageLabel(e.stage) }}</td>
|
||||||
|
<td class="wrap">{{ e.remarks || '-' }}</td>
|
||||||
|
<td>{{ e.actor }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="close()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
+4
-6
@@ -6,10 +6,8 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NgModule } from '@angular/core';
|
@use '../i4c' as *;
|
||||||
import { LoanEligibilityRoutingModule } from './loan-eligibility-routing.module';
|
@use '../i4c-cases' as *;
|
||||||
|
|
||||||
@NgModule({
|
@include i4c-skin;
|
||||||
imports: [LoanEligibilityRoutingModule]
|
@include i4c-case-page;
|
||||||
})
|
|
||||||
export class LoanEligibilityModule {}
|
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
|
||||||
|
|
||||||
|
export const GRM_STAGES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'DRAFT', label: 'Draft (EDD)' },
|
||||||
|
{ value: 'NODAL_REVIEW', label: 'Bank nodal review' },
|
||||||
|
{ value: 'WITH_IO', label: 'With IO (7 days)' },
|
||||||
|
{ value: 'IO_INVESTIGATION', label: 'IO investigating (15 days)' },
|
||||||
|
{ value: 'WITH_DGRO', label: 'District GRO review (15 days)' },
|
||||||
|
{ value: 'WITH_SGRO', label: 'State GRO review (15 days)' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GRM_DECISIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'REMOVE_HOLD', label: 'Remove put on hold' },
|
||||||
|
{ value: 'UNFREEZE_HOLD_CONTINUES', label: 'Unfreeze the account, hold continues' },
|
||||||
|
{ value: 'UNFREEZE_AND_REMOVE_HOLD', label: 'Unfreeze and remove put on hold' },
|
||||||
|
{ value: 'PARTIAL_HOLD', label: 'Keep a smaller amount on hold' },
|
||||||
|
{ value: 'REJECTED_CONTINUE', label: 'Request rejected, hold / freeze continues' },
|
||||||
|
{ value: 'SEEK_INFO', label: 'Seek additional information' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const REQUESTS: Record<string, string> = {
|
||||||
|
REMOVE_HOLD: 'Remove the hold',
|
||||||
|
UNFREEZE: 'Unfreeze the account',
|
||||||
|
RESTORE_DIGITAL_SERVICES: 'Restore digital services'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GRM grievances raised by the bank for account holders who contest an I4C hold: EDD, raising on GRM, the IO / DGRO /
|
||||||
|
* SGRO decision with its deadlines, escalation, and carrying an allowing order out on the account.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-grievances',
|
||||||
|
templateUrl: './i4c-grievances.component.html',
|
||||||
|
styleUrls: ['./i4c-grievances.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
FaIconComponent,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
I4cDocumentsComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cGrievancesComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly stages = GRM_STAGES;
|
||||||
|
readonly decisions = GRM_DECISIONS;
|
||||||
|
readonly noteEvents = [
|
||||||
|
{ value: 'VC_REQUESTED', label: 'IO requested a VC' },
|
||||||
|
{ value: 'VC_DONE', label: 'VC held' },
|
||||||
|
{ value: 'VC_NOT_REQUIRED', label: 'VC not required' },
|
||||||
|
{ value: 'INFO_SOUGHT', label: 'Additional information sought' },
|
||||||
|
{ value: 'INFO_SENT', label: 'Information sent' },
|
||||||
|
{ value: 'NOTE', label: 'Note' }
|
||||||
|
];
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
|
||||||
|
stage = 'OPEN';
|
||||||
|
status = '';
|
||||||
|
overdueOnly = false;
|
||||||
|
search = '';
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
rows: any[] = [];
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
selected: any = null;
|
||||||
|
busy = false;
|
||||||
|
tab: 'overview' | 'workflow' | 'documents' | 'history' = 'workflow';
|
||||||
|
edd = { kycVerified: true, sourceOfFunds: '', customerStatement: '', risk: 'LOW', recommendation: 'SUPPORT' };
|
||||||
|
grievanceNo = '';
|
||||||
|
remarks = '';
|
||||||
|
noteType = 'VC_REQUESTED';
|
||||||
|
decision = { decision: 'REMOVE_HOLD', keepAmount: null as number | null, orderRef: '', orderDate: '', remarks: '' };
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const open = Number(this.route.snapshot.queryParamMap.get('open'));
|
||||||
|
this.load();
|
||||||
|
if (open) {
|
||||||
|
this.open(open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.grievances({
|
||||||
|
stage: this.stage,
|
||||||
|
status: this.status,
|
||||||
|
overdue: this.overdueOnly || null,
|
||||||
|
search: this.search.trim(),
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStage(s: string): void {
|
||||||
|
this.stage = s;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(this.status || this.overdueOnly || this.search || this.dates.preset !== 'ALL' || this.stage !== 'OPEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters(): void {
|
||||||
|
this.stage = 'OPEN';
|
||||||
|
this.status = '';
|
||||||
|
this.overdueOnly = false;
|
||||||
|
this.search = '';
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
open(id: number): void {
|
||||||
|
this.i4c.grievance(id).subscribe({
|
||||||
|
next: (g) => {
|
||||||
|
const same = this.selected?.id === g.id;
|
||||||
|
this.selected = g;
|
||||||
|
if (!same) {
|
||||||
|
this.tab = 'workflow';
|
||||||
|
this.remarks = '';
|
||||||
|
this.grievanceNo = g.grievance_no || '';
|
||||||
|
this.decision = { decision: 'REMOVE_HOLD', keepAmount: null, orderRef: '', orderDate: '', remarks: '' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
stageLabel(s: string): string {
|
||||||
|
return GRM_STAGES.find((x) => x.value === s)?.label || s;
|
||||||
|
}
|
||||||
|
|
||||||
|
decisionLabel(d: string): string {
|
||||||
|
return GRM_DECISIONS.find((x) => x.value === d)?.label || d;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLabel(r: string): string {
|
||||||
|
return REQUESTS[r] || r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stages on the way, for the little progress line; escalation stages show only once reached. */
|
||||||
|
flow(g: any): { value: string; label: string; state: 'done' | 'now' | 'todo' }[] {
|
||||||
|
const path = [
|
||||||
|
'DRAFT',
|
||||||
|
'NODAL_REVIEW',
|
||||||
|
'WITH_IO',
|
||||||
|
'IO_INVESTIGATION',
|
||||||
|
'WITH_DGRO',
|
||||||
|
'WITH_SGRO',
|
||||||
|
'CLOSED'
|
||||||
|
];
|
||||||
|
const at = path.indexOf(g.stage);
|
||||||
|
const visible = path.filter((s, i) => i <= Math.max(at, 3) || s === 'CLOSED' || i <= at);
|
||||||
|
return visible.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: (GRM_STAGES.find((x) => x.value === s)?.label || s).replace(/ \(.*\)/, ''),
|
||||||
|
state: path.indexOf(s) < at ? 'done' : s === g.stage ? 'now' : 'todo'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
get level(): string {
|
||||||
|
const s = this.selected?.stage;
|
||||||
|
return s === 'WITH_DGRO' ? 'DGRO' : s === 'WITH_SGRO' ? 'SGRO' : 'IO';
|
||||||
|
}
|
||||||
|
|
||||||
|
get withOfficer(): boolean {
|
||||||
|
return [
|
||||||
|
'WITH_IO',
|
||||||
|
'IO_INVESTIGATION',
|
||||||
|
'WITH_DGRO',
|
||||||
|
'WITH_SGRO'
|
||||||
|
].includes(this.selected?.stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
isLate(ts: string | null): boolean {
|
||||||
|
return !!ts && new Date(ts).getTime() < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
step(step: string, body: Record<string, any>, done: string): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.grievanceStep(this.selected.id, step, body).subscribe({
|
||||||
|
next: (g) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.selected = g;
|
||||||
|
this.remarks = '';
|
||||||
|
this.snackBar.open(done, 'OK', { duration: 4000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveEdd(): void {
|
||||||
|
this.step('edd', this.edd, 'Due diligence recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
this.step('submit', { grievanceNo: this.grievanceNo.trim() }, 'Grievance marked as raised on GRM.');
|
||||||
|
}
|
||||||
|
|
||||||
|
nodal(approve: boolean): void {
|
||||||
|
this.step(
|
||||||
|
'nodal',
|
||||||
|
{ approve, remarks: this.remarks },
|
||||||
|
approve ? 'Approved; now with the IO (7 days).' : 'Grievance rejected by the nodal officer.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
note(): void {
|
||||||
|
this.step('events', { eventType: this.noteType, remarks: this.remarks }, 'Noted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
investigation(): void {
|
||||||
|
this.step('investigation', { remarks: this.remarks }, 'IO investigation recorded (15 days).');
|
||||||
|
}
|
||||||
|
|
||||||
|
decide(): void {
|
||||||
|
this.step(
|
||||||
|
'decision',
|
||||||
|
{
|
||||||
|
level: this.level,
|
||||||
|
decision: this.decision.decision,
|
||||||
|
keepAmount: this.decision.decision === 'PARTIAL_HOLD' ? this.decision.keepAmount : null,
|
||||||
|
orderRef: this.decision.orderRef,
|
||||||
|
orderDate: this.decision.orderDate || null,
|
||||||
|
remarks: this.decision.remarks
|
||||||
|
},
|
||||||
|
'Decision recorded.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
escalate(): void {
|
||||||
|
this.step('escalate', { remarks: this.remarks }, 'Escalated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
withdraw(): void {
|
||||||
|
this.step('withdraw', { remarks: this.remarks }, 'Grievance withdrawn.');
|
||||||
|
}
|
||||||
|
|
||||||
|
raiseAction(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.raiseGrievanceAction(this.selected.id).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(
|
||||||
|
r.actionRequestId
|
||||||
|
? 'Request ' + r.actionRequestId + ' raised. Another officer approves it on the Approvals page.'
|
||||||
|
: 'Nothing to change on the account; grievance closed.',
|
||||||
|
'OK',
|
||||||
|
{ duration: 6000 }
|
||||||
|
);
|
||||||
|
this.open(this.selected.id);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
missingDocs(g: any): string[] {
|
||||||
|
return (g.requiredDocuments || []).filter((t: string) => !(g.documents || []).some((d: any) => d.doc_type === t));
|
||||||
|
}
|
||||||
|
|
||||||
|
get escalateLabel(): string {
|
||||||
|
const s = this.selected?.stage;
|
||||||
|
return s === 'WITH_SGRO'
|
||||||
|
? 'Close: customer may approach the court'
|
||||||
|
: s === 'WITH_DGRO'
|
||||||
|
? 'Escalate to State GRO'
|
||||||
|
: 'Escalate to District GRO';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,794 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<!-- Modern Toolbar & Filter Chips -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
@for (v of views; track v.value) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="view === v.value" (click)="setView(v.value)">
|
||||||
|
<span>{{ v.label }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||||
|
<mat-label>Account / acknowledgement / customer</mat-label>
|
||||||
|
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" placeholder="Type & hit enter..." />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)">
|
||||||
|
<fa-icon icon="search"></fa-icon> Search
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="count-tag">{{ total }} holds</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Account type</mat-label>
|
||||||
|
<mat-select [(ngModel)]="accountType" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All accounts</mat-option>
|
||||||
|
<mat-option value="SAVINGS">Savings</mat-option>
|
||||||
|
<mat-option value="CURRENT">Current</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Customer intimation</mat-label>
|
||||||
|
<mat-select [(ngModel)]="intimationFilter" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="DONE">Customer told</mat-option>
|
||||||
|
<mat-option value="PENDING">Not yet told</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Layer</mat-label>
|
||||||
|
<mat-select [(ngModel)]="layer" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All layers</mat-option>
|
||||||
|
<mat-option value="0">Layer 0</mat-option>
|
||||||
|
<mat-option value="1">Layer 1+</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-checkbox [(ngModel)]="frozenOnly" (change)="load(true)">Debits frozen only</mat-checkbox>
|
||||||
|
<mifosx-i4c-date-range label="Held on" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading || busy) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Holds Register Table -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="lock"></fa-icon>
|
||||||
|
<span>I4C Account Holds Register</span>
|
||||||
|
<span class="head-hint">Only disputed amounts are restricted; remaining balance stays usable by customer.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Customer Account</th>
|
||||||
|
<th>Account Holder / Branch</th>
|
||||||
|
<th>Ack No.</th>
|
||||||
|
<th class="num">Layer</th>
|
||||||
|
<th class="num">Disputed (₹)</th>
|
||||||
|
<th class="num">Held (₹)</th>
|
||||||
|
<th class="num">Short (₹)</th>
|
||||||
|
<th>Hold Status</th>
|
||||||
|
<th>Customer Intimation</th>
|
||||||
|
<th>90-Day Review</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (h of rows; track h.id) {
|
||||||
|
<tr class="clickable" (click)="select(h.id)">
|
||||||
|
<td>
|
||||||
|
<div class="acc-cell">
|
||||||
|
<b>{{ h.account_no }}</b>
|
||||||
|
<span class="type-tag">{{ h.account_type }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="client-name">{{ h.client_name || 'N/A' }}</div>
|
||||||
|
<div class="sub">{{ h.office_name }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="ack-cell">
|
||||||
|
<span class="ack-no">{{ h.acknowledgement_no }}</span>
|
||||||
|
<button
|
||||||
|
class="copy-chip"
|
||||||
|
(click)="copyText(h.acknowledgement_no, 'ack-' + h.id, $event)"
|
||||||
|
title="Copy Ack No."
|
||||||
|
>
|
||||||
|
<fa-icon icon="copy"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="num font-semibold">{{ h.layer }}</td>
|
||||||
|
<td class="num">₹{{ h.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num font-bold text-accent">₹{{ h.amount_held | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (h.shortfall > 0) {
|
||||||
|
<span class="short-pill">₹{{ h.shortfall | number: '1.2-2' }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="statusClass(h)">{{ h.status }}</span>
|
||||||
|
@if (h.debit_frozen) {
|
||||||
|
<span class="i4c-badge i4c-badge--red">DEBIT FROZEN</span>
|
||||||
|
}
|
||||||
|
@if (h.pending_actions) {
|
||||||
|
<span class="i4c-badge i4c-badge--purple">APPROVAL PENDING</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (h.customer_intimated_at) {
|
||||||
|
<span class="intimation-ok">
|
||||||
|
<fa-icon icon="check-circle"></fa-icon>
|
||||||
|
<span>{{ h.customer_intimated_at | date: 'dd MMM' }} ({{ h.intimation_mode }})</span>
|
||||||
|
</span>
|
||||||
|
} @else if (isOverdue(h)) {
|
||||||
|
<span class="i4c-badge i4c-badge--red">OVERDUE</span>
|
||||||
|
} @else if (h.amount_held > 0 && h.status === 'ACTIVE') {
|
||||||
|
<span class="i4c-badge i4c-badge--amber">INTIMATION DUE</span>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span [class.due-alert]="isReviewDue(h)" class="review-date">
|
||||||
|
@if (isReviewDue(h)) {
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
}
|
||||||
|
{{ h.review_due_on }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="lock"></fa-icon></div>
|
||||||
|
<div>No holds found in this view.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Holds pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: HOLD DETAILS & MAKER ACTIONS MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (selected; as h) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="close()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<!-- Modal Header -->
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="lock"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span>Hold #{{ h.id }} on Account {{ h.account_no }}</span>
|
||||||
|
<span class="i4c-badge" [class]="statusClass(h)">{{ h.status }}</span>
|
||||||
|
@if (h.debit_frozen) {
|
||||||
|
<span class="i4c-badge i4c-badge--red">DEBIT FROZEN</span>
|
||||||
|
}
|
||||||
|
@if (hasPendingAction) {
|
||||||
|
<span class="i4c-badge i4c-badge--purple">APPROVAL PENDING</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
Customer: <b>{{ h.client_name || 'N/A' }}</b> ({{ h.office_name }}) • Ack: {{ h.acknowledgement_no }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__actions">
|
||||||
|
<button class="modal-close-btn" (click)="close()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Tabs inside the Modal -->
|
||||||
|
<div class="i4c-modal-tabs">
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'overview'" (click)="modalTab = 'overview'">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> Overview & Compliance
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'txns'" (click)="modalTab = 'txns'">
|
||||||
|
<fa-icon icon="table"></fa-icon> Hold Transactions ({{ h.transactions?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
@if ((h.status === 'ACTIVE' && h.amount_held > 0) || h.intimationLog?.length) {
|
||||||
|
<button
|
||||||
|
class="modal-tab"
|
||||||
|
[class.modal-tab--active]="modalTab === 'intimation'"
|
||||||
|
(click)="modalTab = 'intimation'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="bell"></fa-icon> Customer Intimation
|
||||||
|
@if (!h.customer_intimated_at && h.status === 'ACTIVE' && h.amount_held > 0) {
|
||||||
|
<span class="i4c-badge i4c-badge--amber">Pending</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (allowedActions.length > 0) {
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'action'" (click)="modalTab = 'action'">
|
||||||
|
<fa-icon icon="hand-paper"></fa-icon> Request Action (Maker)
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'cases'" (click)="modalTab = 'cases'">
|
||||||
|
<fa-icon icon="balance-scale"></fa-icon> GRM / MRM & Documents
|
||||||
|
@if (openGrievance || openRestoration) {
|
||||||
|
<span class="i4c-badge i4c-badge--amber">Open</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
@if (h.actions?.length) {
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'history'" (click)="modalTab = 'history'">
|
||||||
|
<fa-icon icon="history"></fa-icon> Request Audit ({{ h.actions.length }})
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Body -->
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<!-- TAB 1: OVERVIEW -->
|
||||||
|
@if (modalTab === 'overview') {
|
||||||
|
<div class="i4c-grid-2">
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="shield-alt"></fa-icon> Hold Balances & Account</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Customer Name</dt>
|
||||||
|
<dd>{{ h.client_name || '-' }} ({{ h.office_name }})</dd>
|
||||||
|
|
||||||
|
<dt>Account Number</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ h.account_no }}</b> ({{ h.account_type }})
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>I4C Complaint Ack</dt>
|
||||||
|
<dd>
|
||||||
|
<a [routerLink]="['../requests', h.request_id]" (click)="close()">{{ h.acknowledgement_no }}</a>
|
||||||
|
(Layer {{ h.layer }})
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Disputed Amount</dt>
|
||||||
|
<dd>₹{{ h.disputed_amount | number: '1.2-2' }}</dd>
|
||||||
|
|
||||||
|
<dt>Amount Held</dt>
|
||||||
|
<dd>
|
||||||
|
<b class="amount-held-text">₹{{ h.amount_held | number: '1.2-2' }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Shortfall</dt>
|
||||||
|
<dd>
|
||||||
|
@if (h.shortfall > 0) {
|
||||||
|
<span class="err-text">₹{{ h.shortfall | number: '1.2-2' }} (Prospective hold)</span>
|
||||||
|
} @else {
|
||||||
|
<span>Fully covered (₹0.00)</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="calendar-check"></fa-icon> Compliance & Directions</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Hold Placed On</dt>
|
||||||
|
<dd>{{ h.held_at | date: 'dd MMM yyyy, HH:mm:ss' }} (System automated)</dd>
|
||||||
|
|
||||||
|
<dt>90-Day Review Due</dt>
|
||||||
|
<dd>
|
||||||
|
<span [class.due-alert]="isReviewDue(h)">{{ h.review_due_on }}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Debit Freeze Status</dt>
|
||||||
|
<dd>
|
||||||
|
@if (h.debit_frozen) {
|
||||||
|
<span class="i4c-badge i4c-badge--red">FROZEN</span>
|
||||||
|
} @else {
|
||||||
|
<span>Active (Debit Not Frozen)</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Customer Intimation</dt>
|
||||||
|
<dd>
|
||||||
|
@if (h.customer_intimated_at) {
|
||||||
|
<span class="intimation-ok">
|
||||||
|
{{ h.customer_intimated_at | date: 'dd MMM yyyy, HH:mm' }} by {{ h.intimated_by }} ({{
|
||||||
|
h.intimation_mode
|
||||||
|
}}{{ h.intimation_reference ? ', ' + h.intimation_reference : '' }})
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="err-text">
|
||||||
|
Not told yet{{
|
||||||
|
h.intimation_due_by ? ' - due by ' + (h.intimation_due_by | date: 'dd MMM yyyy, HH:mm') : ''
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
@if (h.status === 'RELEASED') {
|
||||||
|
<dt>Released On</dt>
|
||||||
|
<dd>
|
||||||
|
{{ h.released_at | date: 'dd MMM yyyy, HH:mm' }} -
|
||||||
|
{{ reasonLabel(h.release_reason_type || '') }}
|
||||||
|
{{ h.release_reference }}
|
||||||
|
</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 2: TRANSACTIONS -->
|
||||||
|
@if (modalTab === 'txns') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="table"></fa-icon> Account Hold Transactions</div>
|
||||||
|
<div class="i4c-card__body i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account Txn ID</th>
|
||||||
|
<th class="num">Held Amount (₹)</th>
|
||||||
|
<th>Placed Timestamp</th>
|
||||||
|
<th>Released Timestamp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (t of h.transactions; track t.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ t.account_txn_id }}</b>
|
||||||
|
</td>
|
||||||
|
<td class="num">₹{{ t.amount | number: '1.2-2' }}</td>
|
||||||
|
<td>{{ t.placed_at | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||||
|
<td>{{ t.released_at ? (t.released_at | date: 'dd MMM yyyy, HH:mm') : '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="i4c-empty">
|
||||||
|
No money held yet (prospective hold waiting for funds deposit).
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 3: CUSTOMER INTIMATION (automatic channels, manual record, history) -->
|
||||||
|
@if (modalTab === 'intimation') {
|
||||||
|
@if (h.customer_intimated_at) {
|
||||||
|
<div class="intim-status intim-status--done">
|
||||||
|
<fa-icon icon="check"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<b>Customer told</b> on {{ h.customer_intimated_at | date: 'dd MMM yyyy, HH:mm' }} by
|
||||||
|
{{ channelLabel(h.intimation_mode || '')
|
||||||
|
}}{{ h.intimation_reference ? ' - ' + h.intimation_reference : '' }} ({{ h.intimated_by }})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else if (h.automaticChannels?.length) {
|
||||||
|
<div class="intim-status intim-status--auto">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<b>Automatic intimation is on</b> ({{ autoChannelsText(h) }}).
|
||||||
|
@if (h.intimationLog?.length) {
|
||||||
|
The last automatic attempt did not reach the customer; the system retries up to 3 times. You can
|
||||||
|
send again now, or call the customer and record it below.
|
||||||
|
} @else {
|
||||||
|
The notice is sent as soon as the hold is placed. If it has not gone yet, send it now.
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button mat-flat-button color="primary" (click)="sendIntimationNow()" [disabled]="busy">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> Send now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="intim-status intim-status--manual">
|
||||||
|
<fa-icon icon="phone"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<b>No automatic channel is configured.</b> Call the customer (or send a letter / meet in person), then
|
||||||
|
record it below. Channels are switched on under Intimation Settings.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!h.customer_intimated_at && h.status === 'ACTIVE' && h.amount_held > 0) {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="bell"></fa-icon> Record Customer Intimation (manual)</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<div class="i4c-note">
|
||||||
|
RBI draft directions mandate that the account holder be notified of an account hold by the end of
|
||||||
|
the next day, including reason, restoration options, and Grievance Officer contacts.
|
||||||
|
</div>
|
||||||
|
<div class="i4c-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>How the customer was told</mat-label>
|
||||||
|
<mat-select [(ngModel)]="intimation.mode">
|
||||||
|
@for (m of intimationModes; track m) {
|
||||||
|
<mat-option [value]="m">{{ channelLabel(m) }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Reference (call note, letter no.)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="intimation.reference"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="e.g. Called 98xxxx1234, customer informed"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-flat-button color="primary" (click)="submitIntimation()" [disabled]="busy">
|
||||||
|
<fa-icon icon="bell"></fa-icon> Record Customer Intimation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="history"></fa-icon> Intimation History ({{ h.intimationLog?.length || 0 }})
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>When</th>
|
||||||
|
<th>Channel</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Sent to</th>
|
||||||
|
<th>Reference / Reason</th>
|
||||||
|
<th>By</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (l of h.intimationLog || []; track l.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ l.created_at | date: 'dd MMM yyyy, HH:mm' }}</td>
|
||||||
|
<td>{{ channelLabel(l.channel) }}</td>
|
||||||
|
<td>
|
||||||
|
{{ l.mode === 'AUTO' ? 'Automatic' : l.mode === 'RESEND' ? 'Sent by officer' : 'Recorded' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="
|
||||||
|
l.status === 'SENT' || l.status === 'DELIVERED' || l.status === 'RECORDED'
|
||||||
|
"
|
||||||
|
[class.i4c-badge--red]="l.status === 'FAILED'"
|
||||||
|
[class.i4c-badge--amber]="l.status === 'SKIPPED_NO_DESTINATION'"
|
||||||
|
>{{ l.status }}</span
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td>{{ l.destination_masked || '-' }}</td>
|
||||||
|
<td class="wrap">{{ l.failure_reason || l.provider_reference || '-' }}</td>
|
||||||
|
<td>{{ l.actor || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="i4c-empty">No notice sent or recorded yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 4: MAKER CHANGE REQUEST FORM -->
|
||||||
|
@if (modalTab === 'action') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="hand-paper"></fa-icon> Raise Request on Hold (Maker)</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@if (hasPendingAction) {
|
||||||
|
<div class="i4c-note">
|
||||||
|
A request on this hold is already waiting for checker approval on the Approvals page.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="i4c-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Requested Action</mat-label>
|
||||||
|
<mat-select [(ngModel)]="action.action">
|
||||||
|
@for (a of allowedActions; track a.value) {
|
||||||
|
<mat-option [value]="a.value">{{ a.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (action.action === 'RELEASE') {
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Amount to release (₹, empty = all)</mat-label>
|
||||||
|
<input matInput type="number" min="0" [max]="h.amount_held" [(ngModel)]="action.amount" />
|
||||||
|
@if (remainingAfterRelease !== null) {
|
||||||
|
<mat-hint
|
||||||
|
>₹{{ remainingAfterRelease | number: '1.2-2' }} stays on hold (order names the amount to
|
||||||
|
keep)</mat-hint
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Reason</mat-label>
|
||||||
|
<mat-select [(ngModel)]="action.reasonType">
|
||||||
|
@for (r of reasonTypes; track r.value) {
|
||||||
|
<mat-option [value]="r.value">{{ r.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Order / Reference No.{{ orderRequired ? ' *' : '' }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="action.orderReference"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="IO / Court reference..."
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Order Date</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="action.orderDate" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Issuing Authority (IO, Court, DGRO...)</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="action.authority"
|
||||||
|
maxlength="200"
|
||||||
|
placeholder="e.g. Cyber Cell IO..."
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Maker Remarks</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="action.remarks"
|
||||||
|
maxlength="1000"
|
||||||
|
placeholder="Notes for checker..."
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-flat-button color="warn" (click)="submitAction()" [disabled]="busy">
|
||||||
|
<fa-icon icon="hand-paper"></fa-icon> Submit for Checker Approval
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB: GRM grievance, MRM restoration, statement and documents of this hold -->
|
||||||
|
@if (modalTab === 'cases') {
|
||||||
|
<div class="cases-grid">
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="balance-scale"></fa-icon> GRM grievance (customer contests the hold)
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@for (g of h.grievances || []; track g.id) {
|
||||||
|
<div
|
||||||
|
class="case-row"
|
||||||
|
(click)="goTo('grievances', g.id)"
|
||||||
|
(keyup.enter)="goTo('grievances', g.id)"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<b>{{ g.grievance_no || 'Draft #' + g.id }}</b>
|
||||||
|
<span class="i4c-badge">{{ g.stage }}</span>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="g.status === 'ALLOWED'"
|
||||||
|
[class.i4c-badge--red]="g.status === 'REJECTED'"
|
||||||
|
>{{ g.status }}</span
|
||||||
|
>
|
||||||
|
<span class="sub">{{ g.created_at | date: 'dd MMM yyyy' }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (h.status === 'ACTIVE' && !openGrievance) {
|
||||||
|
<div class="i4c-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Customer asks to</mat-label>
|
||||||
|
<mat-select [(ngModel)]="grievanceForm.customerRequest">
|
||||||
|
<mat-option value="REMOVE_HOLD">Remove the hold</mat-option>
|
||||||
|
<mat-option value="UNFREEZE">Unfreeze the account</mat-option>
|
||||||
|
<mat-option value="RESTORE_DIGITAL_SERVICES">Restore digital services</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Customer's reason</mat-label>
|
||||||
|
<input matInput [(ngModel)]="grievanceForm.customerReason" maxlength="2000" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-flat-button color="primary" (click)="raiseGrievance()" [disabled]="busy">
|
||||||
|
<fa-icon icon="plus"></fa-icon> Open grievance (EDD next)
|
||||||
|
</button>
|
||||||
|
} @else if (!h.grievances?.length) {
|
||||||
|
<div class="sub">No grievance on this hold.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="hand-holding-usd"></fa-icon> MRM money restoration</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@for (r of h.restorations || []; track r.id) {
|
||||||
|
<div
|
||||||
|
class="case-row"
|
||||||
|
(click)="goTo('restorations', r.id)"
|
||||||
|
(keyup.enter)="goTo('restorations', r.id)"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<b>{{ r.mrm_request_no || 'Restoration #' + r.id }}</b>
|
||||||
|
<span class="i4c-badge">{{ r.status }}</span>
|
||||||
|
<span class="sub"
|
||||||
|
>{{ r.basis }}
|
||||||
|
{{ r.restorable_amount ? '₹' + (r.restorable_amount | number: '1.2-2') : '' }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (h.status === 'ACTIVE' && !openRestoration) {
|
||||||
|
<div class="i4c-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Restoration</mat-label>
|
||||||
|
<mat-select [(ngModel)]="assessForm.basis">
|
||||||
|
@for (b of mrmBases; track b.value) {
|
||||||
|
<mat-option [value]="b.value">{{ b.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (assessForm.basis === 'ATTRIBUTION' || assessForm.basis === 'PRO_RATA') {
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Restorable amount (₹, at most {{ h.amount_held | number: '1.2-2' }})</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="assessForm.restorableAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Remarks</mat-label>
|
||||||
|
<input matInput [(ngModel)]="assessForm.remarks" maxlength="1000" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-flat-button color="primary" (click)="assessForMrm()" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Assess for MRM
|
||||||
|
</button>
|
||||||
|
} @else if (!h.restorations?.length) {
|
||||||
|
<div class="sub">No restoration on this hold.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="file-csv"></fa-icon> Bank statement (GRM / MRM ask for it)
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<div class="i4c-form">
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>From (default 90 days back)</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="statement.fromDate" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>To (default today)</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="statement.toDate" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<button mat-stroked-button (click)="downloadStatement()">
|
||||||
|
<fa-icon icon="download"></fa-icon> Download statement (CSV)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="paperclip"></fa-icon> Documents on this hold (IO / court orders...)
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<mifosx-i4c-documents
|
||||||
|
ownerType="HOLD"
|
||||||
|
[ownerId]="h.id"
|
||||||
|
[suggested]="['IO_ORDER', 'COURT_ORDER', 'OTHER']"
|
||||||
|
[documents]="h.documents || null"
|
||||||
|
(changed)="select(h.id)"
|
||||||
|
></mifosx-i4c-documents>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 5: REQUEST AUDIT HISTORY -->
|
||||||
|
@if (modalTab === 'history') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="history"></fa-icon> Requests Audit History on this Hold</div>
|
||||||
|
<div class="i4c-card__body i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Reason / Order</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Maker Details</th>
|
||||||
|
<th>Checker Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (a of h.actions; track a.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ a.action }}</b>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ reasonLabel(a.reason_type) }}
|
||||||
|
<div class="sub">{{ a.order_reference }} {{ a.authority ? '- ' + a.authority : '' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ a.status }}</span>
|
||||||
|
@if (a.rejection_reason || a.failure_message) {
|
||||||
|
<div class="sub err-text">{{ a.rejection_reason || a.failure_message }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ a.maker_name }}
|
||||||
|
<div class="sub">{{ a.maker_at | date: 'dd MMM, HH:mm' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ a.checker_name || '-' }}
|
||||||
|
@if (a.checker_at) {
|
||||||
|
<div class="sub">{{ a.checker_at | date: 'dd MMM, HH:mm' }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Footer -->
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="close()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acc-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ack-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.ack-no {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intimation-ok {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: #059669;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-alert {
|
||||||
|
color: #d97706;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-accent {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-held-text {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.intim-status {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border: 1px solid;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--done {
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--auto {
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1e3a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--manual {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cases-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 900px) {
|
||||||
|
.cases-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
border-color: #2563eb;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { HOLD_REASON_TYPES, HoldActionRequest, I4cHold, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
|
||||||
|
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
|
||||||
|
import { MRM_BASES } from '../i4c-restorations/i4c-restorations.component';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The register of every hold placed for I4C. Placing is automatic; from here an officer asks for a release or a
|
||||||
|
* debit freeze/unfreeze (approved by someone else on the Approvals page), and records that the customer was told
|
||||||
|
* about the hold.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-holds',
|
||||||
|
templateUrl: './i4c-holds.component.html',
|
||||||
|
styleUrls: ['./i4c-holds.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
I4cDocumentsComponent,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cHoldsComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly views = [
|
||||||
|
{ value: 'ACTIVE', label: 'Active' },
|
||||||
|
{ value: 'PROSPECTIVE', label: 'Prospective (still short)' },
|
||||||
|
{ value: 'INTIMATION_DUE', label: 'Customer not yet told' },
|
||||||
|
{ value: 'REVIEW_DUE', label: '90-day review due' },
|
||||||
|
{ value: 'NOT_HOLDABLE', label: 'Not holdable' },
|
||||||
|
{ value: 'RELEASED', label: 'Released' },
|
||||||
|
{ value: '', label: 'All' }
|
||||||
|
];
|
||||||
|
readonly reasonTypes = HOLD_REASON_TYPES;
|
||||||
|
/** Ways an officer can tell the customer and record it by hand; phone call comes first. */
|
||||||
|
readonly intimationModes = [
|
||||||
|
'PHONE',
|
||||||
|
'LETTER',
|
||||||
|
'IN_PERSON',
|
||||||
|
'SMS',
|
||||||
|
'EMAIL'
|
||||||
|
];
|
||||||
|
|
||||||
|
view = 'ACTIVE';
|
||||||
|
search = '';
|
||||||
|
accountType = '';
|
||||||
|
intimationFilter = '';
|
||||||
|
layer = '';
|
||||||
|
frozenOnly = false;
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
rows: I4cHold[] = [];
|
||||||
|
selected: I4cHold | null = null;
|
||||||
|
loading = false;
|
||||||
|
busy = false;
|
||||||
|
error = '';
|
||||||
|
modalTab: 'overview' | 'txns' | 'intimation' | 'action' | 'cases' | 'history' = 'overview';
|
||||||
|
readonly mrmBases = MRM_BASES;
|
||||||
|
grievanceForm = { customerRequest: 'REMOVE_HOLD', customerReason: '' };
|
||||||
|
assessForm = { basis: 'ATTRIBUTION', restorableAmount: null as number | null, remarks: '' };
|
||||||
|
statement = { fromDate: '', toDate: '' };
|
||||||
|
copiedKey = '';
|
||||||
|
|
||||||
|
action: HoldActionRequest = this.emptyAction();
|
||||||
|
intimation = { mode: 'PHONE', reference: '' };
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const view = this.route.snapshot.queryParamMap.get('view');
|
||||||
|
if (view !== null) {
|
||||||
|
this.view = view;
|
||||||
|
}
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
setView(v: string): void {
|
||||||
|
this.view = v;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(
|
||||||
|
this.search ||
|
||||||
|
this.accountType ||
|
||||||
|
this.intimationFilter ||
|
||||||
|
this.layer ||
|
||||||
|
this.frozenOnly ||
|
||||||
|
this.dates.preset !== 'ALL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the extra filters; the view chip (Active, Released, ...) stays as chosen. */
|
||||||
|
clearFilters(): void {
|
||||||
|
this.search = '';
|
||||||
|
this.accountType = '';
|
||||||
|
this.intimationFilter = '';
|
||||||
|
this.layer = '';
|
||||||
|
this.frozenOnly = false;
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.holds({
|
||||||
|
view: this.view,
|
||||||
|
search: this.search.trim(),
|
||||||
|
accountType: this.accountType,
|
||||||
|
intimation: this.intimationFilter,
|
||||||
|
layer: this.layer,
|
||||||
|
frozen: this.frozenOnly || null,
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
if (this.selected) {
|
||||||
|
this.select(this.selected.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
select(id: number): void {
|
||||||
|
this.i4c.hold(id).subscribe({
|
||||||
|
next: (hold) => {
|
||||||
|
this.selected = hold;
|
||||||
|
this.action = this.emptyAction();
|
||||||
|
this.intimation = { mode: 'PHONE', reference: '' };
|
||||||
|
this.modalTab = 'overview';
|
||||||
|
},
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyText(text: string, key: string, event?: Event): void {
|
||||||
|
if (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.copiedKey = key;
|
||||||
|
this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedKey === key) {
|
||||||
|
this.copiedKey = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a maker may ask for on this hold, given its state. */
|
||||||
|
get allowedActions(): { value: string; label: string }[] {
|
||||||
|
const h = this.selected;
|
||||||
|
if (!h || h.status === 'RELEASED') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const list = [{ value: 'RELEASE', label: 'Release hold' }];
|
||||||
|
if (h.account_type === 'SAVINGS') {
|
||||||
|
list.push(
|
||||||
|
h.debit_frozen
|
||||||
|
? { value: 'UNFREEZE', label: 'Unfreeze debits, hold continues' }
|
||||||
|
: { value: 'FREEZE', label: 'Freeze all debits (on explicit LEA / court direction only)' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (h.debit_frozen) {
|
||||||
|
list.push({ value: 'UNFREEZE_AND_RELEASE', label: 'Unfreeze and remove the hold' });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
get orderRequired(): boolean {
|
||||||
|
return (
|
||||||
|
(this.action.action === 'RELEASE' || this.action.action === 'UNFREEZE_AND_RELEASE') &&
|
||||||
|
this.action.reasonType !== 'REVIEW_90_DAYS'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What stays on hold after a partial release; null when the whole hold is released. */
|
||||||
|
get remainingAfterRelease(): number | null {
|
||||||
|
const h = this.selected;
|
||||||
|
const amount = Number(this.action.amount);
|
||||||
|
if (!h || this.action.action !== 'RELEASE' || !amount) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.max(0, Number(h.amount_held) - amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
get openGrievance(): any {
|
||||||
|
return (this.selected?.grievances || []).find((g: any) => g.stage !== 'CLOSED');
|
||||||
|
}
|
||||||
|
|
||||||
|
get openRestoration(): any {
|
||||||
|
return (this.selected?.restorations || []).find((r: any) => r.status !== 'CLOSED');
|
||||||
|
}
|
||||||
|
|
||||||
|
raiseGrievance(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.createGrievance(this.selected.id, this.grievanceForm).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.router.navigate(['/cfcfrms/i4c/grievances'], { queryParams: { open: r.resourceId } });
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assessForMrm(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const possible = this.assessForm.basis === 'ATTRIBUTION' || this.assessForm.basis === 'PRO_RATA';
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c
|
||||||
|
.assessRestoration(this.selected.id, {
|
||||||
|
basis: this.assessForm.basis,
|
||||||
|
restorableAmount: possible ? this.assessForm.restorableAmount : null,
|
||||||
|
remarks: this.assessForm.remarks
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.router.navigate(['/cfcfrms/i4c/restorations'], { queryParams: { open: r.resourceId } });
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goTo(page: 'grievances' | 'restorations', id: number): void {
|
||||||
|
this.router.navigate(['/cfcfrms/i4c/' + page], { queryParams: { open: id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadStatement(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const h = this.selected;
|
||||||
|
this.i4c.statementCsv(h.id, this.statement.fromDate || undefined, this.statement.toDate || undefined).subscribe({
|
||||||
|
next: (blob) => saveBlob(blob, 'statement-' + h.account_no + '.csv'),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasPendingAction(): boolean {
|
||||||
|
return (this.selected?.actions || []).some((a: any) => a.status === 'PENDING');
|
||||||
|
}
|
||||||
|
|
||||||
|
submitAction(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.action.reasonType) {
|
||||||
|
this.snackBar.open('Choose the reason.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.orderRequired && !this.action.orderReference?.trim()) {
|
||||||
|
this.snackBar.open('An order / reference number is required to release a hold.', 'Close', { duration: 5000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.requestHoldAction(this.selected.id, this.action).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open('Request raised. Another officer must approve it on the Approvals page.', 'OK', {
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
submitIntimation(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.recordIntimation(this.selected.id, this.intimation.mode, this.intimation.reference).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open('Customer intimation recorded.', 'OK', { duration: 3000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send the hold notice now on the switched-on automatic channels (Email today). */
|
||||||
|
sendIntimationNow(): void {
|
||||||
|
if (!this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.sendIntimation(this.selected.id).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(r.message, 'OK', { duration: 6000 });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
channelLabel(channel: string): string {
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
{
|
||||||
|
EMAIL: 'Email',
|
||||||
|
SMS: 'SMS',
|
||||||
|
WHATSAPP: 'WhatsApp',
|
||||||
|
PHONE: 'Phone call',
|
||||||
|
LETTER: 'Letter',
|
||||||
|
IN_PERSON: 'In person'
|
||||||
|
} as Record<string, string>
|
||||||
|
)[channel] ?? channel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
autoChannelsText(hold: I4cHold): string {
|
||||||
|
return (hold.automaticChannels || []).map((c) => this.channelLabel(c)).join(', then ');
|
||||||
|
}
|
||||||
|
|
||||||
|
isOverdue(hold: I4cHold): boolean {
|
||||||
|
return (
|
||||||
|
hold.status === 'ACTIVE' &&
|
||||||
|
!hold.customer_intimated_at &&
|
||||||
|
!!hold.intimation_due_by &&
|
||||||
|
new Date(hold.intimation_due_by).getTime() < Date.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isReviewDue(hold: I4cHold): boolean {
|
||||||
|
if (hold.status !== 'ACTIVE' || !hold.review_due_on) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const due = new Date(hold.review_due_on + 'T00:00:00').getTime();
|
||||||
|
return due - Date.now() <= 15 * 24 * 3600 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(hold: I4cHold): string {
|
||||||
|
if (hold.status === 'RELEASED') {
|
||||||
|
return 'i4c-badge--blue';
|
||||||
|
}
|
||||||
|
if (hold.status === 'NOT_HOLDABLE') {
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
}
|
||||||
|
return hold.shortfall > 0 ? 'i4c-badge--amber' : 'i4c-badge--green';
|
||||||
|
}
|
||||||
|
|
||||||
|
reasonLabel(value: string): string {
|
||||||
|
return this.reasonTypes.find((r) => r.value === value)?.label || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emptyAction(): HoldActionRequest {
|
||||||
|
return {
|
||||||
|
action: 'RELEASE',
|
||||||
|
amount: null,
|
||||||
|
reasonType: '',
|
||||||
|
orderReference: '',
|
||||||
|
orderDate: '',
|
||||||
|
authority: '',
|
||||||
|
remarks: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">{{ error }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="summary" [class.summary--manual]="!enabledChannels.length">
|
||||||
|
<fa-icon [icon]="enabledChannels.length ? 'check' : 'exclamation-triangle'" class="summary__icon"></fa-icon>
|
||||||
|
<div>
|
||||||
|
@if (enabledChannels.length) {
|
||||||
|
<div class="summary__title">
|
||||||
|
Customers are told automatically by
|
||||||
|
@for (c of enabledChannels; track c.channel; let last = $last) {
|
||||||
|
<b>{{ info(c.channel).label }}</b
|
||||||
|
>{{ last ? '' : ', then ' }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="summary__text">
|
||||||
|
As soon as money is held, the channels are tried in this order. The first that reaches the customer records
|
||||||
|
the intimation on the hold. If none does (no email on file, gateway down), the hold stays "customer not yet
|
||||||
|
told" for an officer to call and record by hand.
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="summary__title">No automatic channel is switched on</div>
|
||||||
|
<div class="summary__text">
|
||||||
|
Every hold waits for an officer to contact the customer (phone, letter, in person) and record it on the hold's
|
||||||
|
Record Intimation tab.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (c of channels; track c.channel) {
|
||||||
|
<div class="i4c-card channel" [class.channel--off]="!c.enabled">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon [icon]="info(c.channel).icon"></fa-icon>
|
||||||
|
<span>{{ info(c.channel).label }}</span>
|
||||||
|
<span class="i4c-badge" [class.i4c-badge--green]="c.enabled">{{ c.enabled ? 'ON' : 'OFF' }}</span>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<mat-slide-toggle
|
||||||
|
[id]="'toggle-' + c.channel"
|
||||||
|
[(ngModel)]="c.enabled"
|
||||||
|
(change)="toggle(c)"
|
||||||
|
[disabled]="c.saving"
|
||||||
|
[attr.aria-label]="'Send automatically by ' + info(c.channel).label"
|
||||||
|
>
|
||||||
|
Send automatically
|
||||||
|
</mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<div class="gateway">Sent through: {{ info(c.channel).gateway }}</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="edit">
|
||||||
|
<div class="row">
|
||||||
|
<mat-form-field appearance="outline" class="priority">
|
||||||
|
<mat-label>Order</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="99"
|
||||||
|
[id]="'priority-' + c.channel"
|
||||||
|
[(ngModel)]="c.priority"
|
||||||
|
(ngModelChange)="c.dirty = true"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (c.channel === 'EMAIL') {
|
||||||
|
<mat-form-field appearance="outline" class="subject">
|
||||||
|
<mat-label>Subject</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
maxlength="300"
|
||||||
|
[id]="'subject-' + c.channel"
|
||||||
|
[(ngModel)]="c.subject"
|
||||||
|
(ngModelChange)="c.dirty = true"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<mat-form-field appearance="outline" class="full">
|
||||||
|
<mat-label>Message</mat-label>
|
||||||
|
<textarea
|
||||||
|
matInput
|
||||||
|
rows="9"
|
||||||
|
[id]="'body-' + c.channel"
|
||||||
|
[(ngModel)]="c.body"
|
||||||
|
(ngModelChange)="c.dirty = true"
|
||||||
|
></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="chips">
|
||||||
|
<span class="chips__label">Insert:</span>
|
||||||
|
@for (p of placeholders; track p) {
|
||||||
|
<button type="button" class="chip" (click)="insert(c, p)">{{ p }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="saveText(c)" [disabled]="!c.dirty || c.saving">
|
||||||
|
<fa-icon icon="check"></fa-icon> Save {{ info(c.channel).label }} message
|
||||||
|
</button>
|
||||||
|
@if (c.updated_at) {
|
||||||
|
<span class="muted"
|
||||||
|
>Last changed {{ c.updated_at | date: 'dd MMM yyyy, HH:mm' }} by {{ c.updated_by }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview">
|
||||||
|
<div class="preview__label">Preview (sample values)</div>
|
||||||
|
@if (c.channel === 'EMAIL') {
|
||||||
|
<div class="preview__subject">{{ preview(c.subject) }}</div>
|
||||||
|
}
|
||||||
|
<div class="preview__body">{{ preview(c.body) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (c.channel === 'EMAIL') {
|
||||||
|
<div class="test">
|
||||||
|
<mat-form-field appearance="outline" class="test__field" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Send a test to</mat-label>
|
||||||
|
<input matInput type="email" id="test-email-to" [(ngModel)]="testTo" placeholder="you@bank.in" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-stroked-button (click)="sendTest()" [disabled]="testing">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> Send test email
|
||||||
|
</button>
|
||||||
|
<span class="muted">Uses the saved message with sample values; the subject starts with [TEST].</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="i4c-note">
|
||||||
|
Phone, letter and in-person intimation are always recorded by an officer on the hold's Record Intimation tab; the
|
||||||
|
CBS cannot send them itself. Every notice, automatic or recorded, is listed in the hold's intimation history.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #166534;
|
||||||
|
|
||||||
|
&--manual {
|
||||||
|
border-color: #fde68a;
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel--off {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gateway {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 1024px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
color: #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 12px 14px;
|
||||||
|
align-self: start;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subject {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: #1e293b;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.test {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, IntimationChannel, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/** Sample values for the live preview; the server fills the real ones per hold. */
|
||||||
|
const SAMPLE: Record<string, string> = {
|
||||||
|
customerName: 'Anshul Rathore',
|
||||||
|
accountNumber: 'XXXXXXXXXXX0006',
|
||||||
|
amountHeld: '1500.00',
|
||||||
|
disputedAmount: '1500.00',
|
||||||
|
ackNo: '31610201726120',
|
||||||
|
holdDate: '20-10-2026',
|
||||||
|
reviewDate: '18-01-2027',
|
||||||
|
bankName: 'LincPay Demo Co-operative Bank',
|
||||||
|
branchName: 'Head Office',
|
||||||
|
grievanceEmail: 'grievance@lpdb.example.in',
|
||||||
|
customerCare: '1800-200-4570'
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNEL_INFO: Record<string, { label: string; icon: string; gateway: string }> = {
|
||||||
|
EMAIL: {
|
||||||
|
label: 'Email',
|
||||||
|
icon: 'envelope',
|
||||||
|
gateway: "CBS mail server (spring.mail settings), to the customer's email on file"
|
||||||
|
},
|
||||||
|
SMS: {
|
||||||
|
label: 'SMS',
|
||||||
|
icon: 'mobile-alt',
|
||||||
|
gateway: "SMS gateway (fineract.notification.sms), to the customer's mobile on file"
|
||||||
|
},
|
||||||
|
WHATSAPP: {
|
||||||
|
label: 'WhatsApp',
|
||||||
|
icon: 'phone',
|
||||||
|
gateway: "WhatsApp gateway (fineract.notification.whatsapp), to the customer's mobile"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EditableChannel extends IntimationChannel {
|
||||||
|
dirty?: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which channels tell the account holder about an I4C hold on their own. Switched-on channels are tried in priority
|
||||||
|
* order as soon as money is held; the first that reaches the customer records the intimation. With none switched on,
|
||||||
|
* or none reaching the customer, the hold waits for an officer to phone or write and record it by hand.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-intimation-settings',
|
||||||
|
templateUrl: './i4c-intimation-settings.component.html',
|
||||||
|
styleUrls: ['./i4c-intimation-settings.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cIntimationSettingsComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
channels: EditableChannel[] = [];
|
||||||
|
placeholders: string[] = [];
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
testTo = '';
|
||||||
|
testing = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c.intimationChannels().subscribe({
|
||||||
|
next: (s) => {
|
||||||
|
this.channels = s.channels.map((c) => ({ ...c }));
|
||||||
|
this.placeholders = s.placeholders;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get enabledChannels(): EditableChannel[] {
|
||||||
|
return this.channels.filter((c) => c.enabled).sort((a, b) => a.priority - b.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(channel: string) {
|
||||||
|
return CHANNEL_INFO[channel] ?? { label: channel, icon: 'bell', gateway: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
preview(text: string | undefined): string {
|
||||||
|
return (text ?? '').replace(/\{(\w+)\}/g, (match, key) => SAMPLE[key] ?? match);
|
||||||
|
}
|
||||||
|
|
||||||
|
insert(channel: EditableChannel, placeholder: string): void {
|
||||||
|
channel.body = (channel.body ?? '') + placeholder;
|
||||||
|
channel.dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The switch saves at once: it decides whether customers get told automatically. */
|
||||||
|
toggle(channel: EditableChannel): void {
|
||||||
|
this.save(channel, { enabled: channel.enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
saveText(channel: EditableChannel): void {
|
||||||
|
if (!channel.body?.trim()) {
|
||||||
|
this.snackBar.open('The message text cannot be empty.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (channel.channel === 'EMAIL' && !channel.subject?.trim()) {
|
||||||
|
this.snackBar.open('An email needs a subject.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.save(channel, { priority: channel.priority, subject: channel.subject, body: channel.body });
|
||||||
|
}
|
||||||
|
|
||||||
|
private save(
|
||||||
|
channel: EditableChannel,
|
||||||
|
body: { enabled?: boolean; priority?: number; subject?: string; body?: string }
|
||||||
|
): void {
|
||||||
|
channel.saving = true;
|
||||||
|
this.i4c.updateIntimationChannel(channel.channel, body).subscribe({
|
||||||
|
next: (rows) => {
|
||||||
|
const fresh = rows.find((r) => r.channel === channel.channel);
|
||||||
|
if (fresh) {
|
||||||
|
Object.assign(channel, fresh, { dirty: false });
|
||||||
|
}
|
||||||
|
channel.saving = false;
|
||||||
|
this.snackBar.open(this.info(channel.channel).label + ' settings saved.', 'OK', { duration: 3000 });
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
channel.saving = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTest(): void {
|
||||||
|
if (!this.testTo.trim()) {
|
||||||
|
this.snackBar.open('Enter an email address for the test.', 'Close', { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.testing = true;
|
||||||
|
this.i4c.testIntimationChannel('EMAIL', this.testTo.trim()).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.testing = false;
|
||||||
|
this.snackBar.open(r.outcome === 'SENT' ? 'Test email sent to ' + this.testTo + '.' : r.message, 'OK', {
|
||||||
|
duration: 6000
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.testing = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||||
|
<mat-label>Filter account / customer / branch / ack</mat-label>
|
||||||
|
<input matInput [(ngModel)]="filter" />
|
||||||
|
</mat-form-field>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<span class="count-tag"
|
||||||
|
>{{ shown.length }} accounts · {{ totals.complaints }} complaints · ₹{{
|
||||||
|
totals.held | number: '1.2-2'
|
||||||
|
}}
|
||||||
|
held</span
|
||||||
|
>
|
||||||
|
<button mat-stroked-button (click)="load()" [disabled]="loading"><fa-icon icon="sync"></fa-icon> Refresh</button>
|
||||||
|
<button mat-flat-button color="primary" (click)="download()">
|
||||||
|
<fa-icon icon="file-csv"></fa-icon> Download for MRM (CSV)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon> <span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="university"></fa-icon>
|
||||||
|
<span>MRM: Update Account Balance</span>
|
||||||
|
<span class="head-hint">
|
||||||
|
Every account with an active I4C hold. Enter these balances on MRM → Money Distribution Process (one by one or
|
||||||
|
with MRM's bulk format). Click an account for its pro-rata split.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Account</th>
|
||||||
|
<th>Holder / Branch</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="num">Ledger balance</th>
|
||||||
|
<th class="num">Available</th>
|
||||||
|
<th class="num">On hold</th>
|
||||||
|
<th class="num">Disputed</th>
|
||||||
|
<th class="num">Complaints</th>
|
||||||
|
<th>Acknowledgement nos.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of shown; track r.account_type + r.account_id) {
|
||||||
|
<tr class="clickable" (click)="pick(r)">
|
||||||
|
<td>
|
||||||
|
<b>{{ r.account_no }}</b>
|
||||||
|
<div class="sub">{{ r.account_type }}{{ r.debit_frozen ? ' · debits frozen' : '' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ r.client_name }}
|
||||||
|
<div class="sub">{{ r.office_name }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ r.account_status || '-' }}</td>
|
||||||
|
<td class="num">{{ r.ledger_balance | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ r.available_balance | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<b>{{ r.held | number: '1.2-2' }}</b>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ r.disputed | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ r.complaints }}</td>
|
||||||
|
<td class="wrap">{{ r.acknowledgement_nos }}</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="i4c-empty">No accounts with active I4C holds.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="calculator"></fa-icon>
|
||||||
|
<span>Pro-rata distribution</span>
|
||||||
|
<span class="head-hint">
|
||||||
|
MRM: when money from several complaints is mixed in one account, it is shared in proportion to the disputed
|
||||||
|
amounts. Leave the amount empty to share everything held.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<div class="i4c-filter-bar i4c-filter-bar--inset">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Account no.</mat-label>
|
||||||
|
<input matInput [(ngModel)]="proRataAccount" (keyup.enter)="calculate()" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Amount to share (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="proRataAmount" (keyup.enter)="calculate()" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-flat-button color="primary" (click)="calculate()" [disabled]="!proRataAccount.trim()">
|
||||||
|
<fa-icon icon="calculator"></fa-icon> Calculate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (proRata) {
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Complaint (ack)</th>
|
||||||
|
<th class="num">Layer</th>
|
||||||
|
<th class="num">Disputed</th>
|
||||||
|
<th class="num">Held for it</th>
|
||||||
|
<th class="num">Share</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (h of proRata.holds; track h.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ h.acknowledgement_no }}</td>
|
||||||
|
<td class="num">{{ h.layer }}</td>
|
||||||
|
<td class="num">{{ h.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ h.amount_held | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<b>{{ h.share | number: '1.2-2' }}</b>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="sub">
|
||||||
|
₹{{ proRata.amount | number: '1.2-2' }} of ₹{{ proRata.totalHeld | number: '1.2-2' }} held on
|
||||||
|
{{ proRata.accountNo }}, by disputed amount (total ₹{{ proRata.totalDisputed | number: '1.2-2' }}). A share
|
||||||
|
never exceeds what that complaint's hold has; use the figure as the restorable amount of that complaint's
|
||||||
|
restoration.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
@use '../i4c-cases' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
@include i4c-case-page;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MRM "Update Account Balance": every account with an active I4C hold and its balances now, for the bank to update on
|
||||||
|
* MRM (one by one or with the bulk sheet), plus the pro-rata calculator for accounts with several complaints.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-mrm-report',
|
||||||
|
templateUrl: './i4c-mrm-report.component.html',
|
||||||
|
styleUrls: ['./i4c-mrm-report.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cMrmReportComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
rows: any[] = [];
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
filter = '';
|
||||||
|
proRataAccount = '';
|
||||||
|
proRataAmount: number | null = null;
|
||||||
|
proRata: any = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c.mrmBalanceReport().subscribe({
|
||||||
|
next: (rows) => {
|
||||||
|
this.rows = rows;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get shown(): any[] {
|
||||||
|
const f = this.filter.trim().toLowerCase();
|
||||||
|
return f
|
||||||
|
? this.rows.filter((r) =>
|
||||||
|
[
|
||||||
|
r.account_no,
|
||||||
|
r.client_name,
|
||||||
|
r.office_name,
|
||||||
|
r.acknowledgement_nos
|
||||||
|
].some((v) =>
|
||||||
|
String(v || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: this.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totals(): { held: number; ledger: number; complaints: number } {
|
||||||
|
return this.shown.reduce(
|
||||||
|
(t, r) => ({
|
||||||
|
held: t.held + Number(r.held || 0),
|
||||||
|
ledger: t.ledger + Number(r.ledger_balance || 0),
|
||||||
|
complaints: t.complaints + Number(r.complaints || 0)
|
||||||
|
}),
|
||||||
|
{ held: 0, ledger: 0, complaints: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
download(): void {
|
||||||
|
this.i4c.mrmBalanceReportCsv().subscribe({
|
||||||
|
next: (blob) => saveBlob(blob, 'mrm-balance-report-' + new Date().toISOString().slice(0, 10) + '.csv'),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pick(r: any): void {
|
||||||
|
this.proRataAccount = r.account_no;
|
||||||
|
this.proRataAmount = null;
|
||||||
|
this.calculate();
|
||||||
|
}
|
||||||
|
|
||||||
|
calculate(): void {
|
||||||
|
if (!this.proRataAccount.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.i4c.proRata(this.proRataAccount.trim(), this.proRataAmount).subscribe({
|
||||||
|
next: (p) => (this.proRata = p),
|
||||||
|
error: (e) => {
|
||||||
|
this.proRata = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<!-- View Switcher & Toolbar -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="activeSection === 'all'" (click)="activeSection = 'all'">
|
||||||
|
<fa-icon icon="list-ul"></fa-icon> <span>All (Outbox & Log)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="filter-chip"
|
||||||
|
[class.filter-chip--active]="activeSection === 'outbox'"
|
||||||
|
(click)="activeSection = 'outbox'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> <span>Responses to I4C ({{ outboxTotal }})</span>
|
||||||
|
</button>
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="activeSection === 'log'" (click)="activeSection = 'log'">
|
||||||
|
<fa-icon icon="history"></fa-icon> <span>API Audit Log ({{ logTotal }})</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" class="refresh-btn" (click)="loadOutbox(); loadLog()" [disabled]="loading">
|
||||||
|
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- SECTION 1: RESPONSES TO I4C (OUTBOX) -->
|
||||||
|
@if (activeSection === 'all' || activeSection === 'outbox') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<span>Outbox Queue: Delivery to I4C</span>
|
||||||
|
<span class="head-hint">Automated HTTP callbacks carrying bank findings to I4C</span>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<!-- Status Filter Chips -->
|
||||||
|
<div class="mini-chips">
|
||||||
|
@for (s of outboxStatuses; track s) {
|
||||||
|
<button class="mini-chip" [class.mini-chip--active]="outboxStatus === s" (click)="setOutboxStatus(s)">
|
||||||
|
{{ s || 'All' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar i4c-filter-bar--inset">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Ack no. / job id</mat-label>
|
||||||
|
<input matInput [(ngModel)]="outboxSearch" (keyup.enter)="loadOutbox(true)" (blur)="loadOutbox(true)" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Type</mat-label>
|
||||||
|
<mat-select [(ngModel)]="outboxType" (selectionChange)="loadOutbox(true)">
|
||||||
|
<mat-option value="">ACK and RESPONSE</mat-option>
|
||||||
|
<mat-option value="ACK">ACK only</mat-option>
|
||||||
|
<mat-option value="RESPONSE">RESPONSE only</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mifosx-i4c-date-range
|
||||||
|
label="Queued"
|
||||||
|
[range]="outboxDates"
|
||||||
|
(rangeChange)="onOutboxDates($event)"
|
||||||
|
></mifosx-i4c-date-range>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="i4c-filter-bar__clear"
|
||||||
|
(click)="clearOutboxFilters()"
|
||||||
|
[disabled]="!outboxHasFilters"
|
||||||
|
>
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Complaint Ack No.</th>
|
||||||
|
<th title="ACK: separate acknowledgement callback. RESPONSE: the Section 6 findings.">Type</th>
|
||||||
|
<th>Delivery Status</th>
|
||||||
|
<th class="num">Attempts</th>
|
||||||
|
<th>Next Retry</th>
|
||||||
|
<th>HTTP Code</th>
|
||||||
|
<th>Sent Timestamp</th>
|
||||||
|
<th>Error</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (o of outbox; track o.id) {
|
||||||
|
<tr class="clickable" (click)="openOutboxModal(o)">
|
||||||
|
<td>
|
||||||
|
<span class="date-main">{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
[routerLink]="['../requests', o.request_id]"
|
||||||
|
class="font-semibold text-accent"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
{{ o.acknowledgement_no }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class.i4c-badge--blue]="o.message_type === 'ACK'">{{
|
||||||
|
o.message_type === 'ACK' ? 'ACK' : 'RESPONSE'
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="o.status === 'SENT'"
|
||||||
|
[class.i4c-badge--red]="o.status === 'FAILED'"
|
||||||
|
[class.i4c-badge--amber]="o.status === 'PENDING' || o.status === 'SENDING'"
|
||||||
|
>
|
||||||
|
{{ o.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="num font-semibold">{{ o.attempts }}</td>
|
||||||
|
<td>{{ o.status === 'PENDING' ? (o.next_attempt_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="font-mono">{{ o.http_status ?? '-' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||||
|
<td class="err-text">{{ o.last_error || '-' }}</td>
|
||||||
|
<td (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button class="table-action-btn" (click)="openOutboxModal(o)">
|
||||||
|
<fa-icon icon="eye"></fa-icon> Payload
|
||||||
|
</button>
|
||||||
|
@if (o.status === 'FAILED') {
|
||||||
|
<button class="retry-btn" (click)="retry(o)" [disabled]="busyId === o.id">
|
||||||
|
<fa-icon icon="redo" [animation]="busyId === o.id ? 'spin' : undefined"></fa-icon> Retry
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="10" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="paper-plane"></fa-icon></div>
|
||||||
|
<div>No responses in the outbox queue matching current filter.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="outboxTotal"
|
||||||
|
[pageIndex]="outboxPageIndex"
|
||||||
|
[pageSize]="outboxPageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onOutboxPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Outbox pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- SECTION 2: API LOG -->
|
||||||
|
@if (activeSection === 'all' || activeSection === 'log') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="history"></fa-icon>
|
||||||
|
<span>API Gateway Audit Trail (Masked)</span>
|
||||||
|
<span class="head-hint">Every call to and from I4C. Click a row to see its request and response.</span>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<!-- Direction Filters -->
|
||||||
|
<div class="mini-chips">
|
||||||
|
<button class="mini-chip" [class.mini-chip--active]="direction === ''" (click)="setDirection('')">
|
||||||
|
Both
|
||||||
|
</button>
|
||||||
|
<button class="mini-chip" [class.mini-chip--active]="direction === 'IN'" (click)="setDirection('IN')">
|
||||||
|
↓ Inbound
|
||||||
|
</button>
|
||||||
|
<button class="mini-chip" [class.mini-chip--active]="direction === 'OUT'" (click)="setDirection('OUT')">
|
||||||
|
↑ Outbound
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-checkbox [(ngModel)]="failuresOnly" (change)="loadLog(true)" class="failures-chk">
|
||||||
|
Failures Only
|
||||||
|
</mat-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar i4c-filter-bar--inset">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Endpoint / ack no. / IP</mat-label>
|
||||||
|
<input matInput [(ngModel)]="logSearch" (keyup.enter)="loadLog(true)" (blur)="loadLog(true)" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>HTTP status</mat-label>
|
||||||
|
<mat-select [(ngModel)]="logHttpStatus" (selectionChange)="loadLog(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="200">200 OK</mat-option>
|
||||||
|
<mat-option value="400">400 Bad request</mat-option>
|
||||||
|
<mat-option value="401">401 Not authorised</mat-option>
|
||||||
|
<mat-option value="403">403 Forbidden</mat-option>
|
||||||
|
<mat-option value="409">409 Duplicate</mat-option>
|
||||||
|
<mat-option value="500">500 Server error</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Response code</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="logResponseCode"
|
||||||
|
(keyup.enter)="loadLog(true)"
|
||||||
|
(blur)="loadLog(true)"
|
||||||
|
placeholder="e.g. 00, 11"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mifosx-i4c-date-range
|
||||||
|
label="Call time"
|
||||||
|
[range]="logDates"
|
||||||
|
(rangeChange)="onLogDates($event)"
|
||||||
|
></mifosx-i4c-date-range>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="i4c-filter-bar__clear"
|
||||||
|
(click)="clearLogFilters()"
|
||||||
|
[disabled]="!logHasFilters"
|
||||||
|
>
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Direction</th>
|
||||||
|
<th>Endpoint</th>
|
||||||
|
<th>HTTP Status</th>
|
||||||
|
<th>Response Code</th>
|
||||||
|
<th>Ack No.</th>
|
||||||
|
<th class="num">Latency (ms)</th>
|
||||||
|
<th>Caller IP</th>
|
||||||
|
<th>Error Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (l of log; track l.id) {
|
||||||
|
<tr class="clickable" (click)="openLogModal(l)">
|
||||||
|
<td>{{ l.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="direction-badge direction-badge--{{ l.direction?.toLowerCase() }}">
|
||||||
|
{{ l.direction === 'IN' ? '↓ I4C → Bank' : '↑ Bank → I4C' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="font-mono text-xs">{{ l.endpoint }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="ok(l.http_status)"
|
||||||
|
[class.i4c-badge--red]="!ok(l.http_status)"
|
||||||
|
>
|
||||||
|
{{ l.http_status ?? 'N/A' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ l.response_code || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ l.acknowledgement_no || '-' }}</b>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ l.duration_ms }} ms</td>
|
||||||
|
<td>{{ l.client_ip || '-' }}</td>
|
||||||
|
<td [class.err-text]="isFailedCall(l)" [class.note-text]="!isFailedCall(l)">
|
||||||
|
{{ logNote(l.error) || '-' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="history"></fa-icon></div>
|
||||||
|
<div>No API calls recorded.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="logTotal"
|
||||||
|
[pageIndex]="logPageIndex"
|
||||||
|
[pageSize]="logPageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onLogPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="API log pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: API LOG JSON INSPECTION MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (selectedLogModal) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeLogModal()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<!-- Modal Header -->
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="code"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span
|
||||||
|
>{{ selectedLogModal.direction === 'IN' ? 'Inbound Call' : 'Outbound Call' }}:
|
||||||
|
{{ selectedLogModal.endpoint }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="ok(selectedLogModal.http_status)"
|
||||||
|
[class.i4c-badge--red]="!ok(selectedLogModal.http_status)"
|
||||||
|
>
|
||||||
|
HTTP {{ selectedLogModal.http_status ?? 'N/A' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
Time: {{ selectedLogModal.created_at | date: 'dd MMM yyyy, HH:mm:ss' }} • Latency:
|
||||||
|
{{ selectedLogModal.duration_ms }} ms • Client IP: {{ selectedLogModal.client_ip }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeLogModal()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Tabs inside the Modal -->
|
||||||
|
<div class="i4c-modal-tabs">
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="logModalTab === 'req'" (click)="logModalTab = 'req'">
|
||||||
|
<fa-icon icon="arrow-up"></fa-icon> Request Payload
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="logModalTab === 'res'" (click)="logModalTab = 'res'">
|
||||||
|
<fa-icon icon="arrow-down"></fa-icon> Response Payload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Body -->
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
@if (logModalTab === 'req') {
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<span>HTTP Request Payload (Masked)</span>
|
||||||
|
<button class="copy-chip" (click)="copyPayload(pretty(selectedLogModal.request_body), 'req-body')">
|
||||||
|
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'req-body' ? 'Copied!' : 'Copy Request' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(selectedLogModal.request_body) || 'No request body sent.' }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (logModalTab === 'res') {
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<span>HTTP Response Payload</span>
|
||||||
|
<button class="copy-chip" (click)="copyPayload(pretty(selectedLogModal.response_body), 'res-body')">
|
||||||
|
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'res-body' ? 'Copied!' : 'Copy Response' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(selectedLogModal.response_body) || 'No response body received.' }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedLogModal.error) {
|
||||||
|
@if (isFailedCall(selectedLogModal)) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>Error logged: {{ logNote(selectedLogModal.error) }}</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="i4c-note">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon>
|
||||||
|
<span>Note: {{ logNote(selectedLogModal.error) }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Footer -->
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeLogModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: OUTBOX RESPONSE INSPECTION MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (selectedOutboxModal) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeOutboxModal()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span
|
||||||
|
>Outbox Response #{{ selectedOutboxModal.id }} (Ack #{{
|
||||||
|
selectedOutboxModal.acknowledgement_no
|
||||||
|
}})</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="selectedOutboxModal.status === 'SENT'"
|
||||||
|
[class.i4c-badge--red]="selectedOutboxModal.status === 'FAILED'"
|
||||||
|
>
|
||||||
|
{{ selectedOutboxModal.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
Created: {{ selectedOutboxModal.created_at | date: 'dd MMM yyyy, HH:mm:ss' }} • Attempts:
|
||||||
|
{{ selectedOutboxModal.attempts }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeOutboxModal()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<span>Report Payload Prepared for I4C Delivery</span>
|
||||||
|
<button class="copy-chip" (click)="copyPayload(pretty(selectedOutboxModal.payload), 'outbox-payload')">
|
||||||
|
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'outbox-payload' ? 'Copied!' : 'Copy Payload' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(selectedOutboxModal.payload) || 'No payload recorded.' }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedOutboxModal.response_body) {
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<span>Response Received from I4C Gateway</span>
|
||||||
|
<button
|
||||||
|
class="copy-chip"
|
||||||
|
(click)="copyPayload(pretty(selectedOutboxModal.response_body), 'outbox-res')"
|
||||||
|
>
|
||||||
|
<fa-icon icon="copy"></fa-icon> {{ copiedKey === 'outbox-res' ? 'Copied!' : 'Copy Response' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(selectedOutboxModal.response_body) }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedOutboxModal.last_error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>Last Delivery Error: {{ selectedOutboxModal.last_error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
@if (selectedOutboxModal.status === 'FAILED') {
|
||||||
|
<button mat-flat-button color="warn" (click)="retry(selectedOutboxModal); closeOutboxModal()">
|
||||||
|
<fa-icon icon="redo"></fa-icon> Retry Send Now
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="footer-btn-close" (click)="closeOutboxModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.compact {
|
||||||
|
width: 160px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 11px;
|
||||||
|
max-width: 240px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-main {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-chips {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
.mini-chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 2px 6px rgb(37 99 235 / 25%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.failures-chk {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&--in {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--out {
|
||||||
|
background: #f5f3ff;
|
||||||
|
color: #6d28d9;
|
||||||
|
border: 1px solid #ddd6fe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #fee2e2;
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-text {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responses waiting for, or delivered to, I4C, with a retry for the failed ones; and the log of every call in
|
||||||
|
* either direction, which is what the periodic review of the API integration reads.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-outbox',
|
||||||
|
templateUrl: './i4c-outbox.component.html',
|
||||||
|
styleUrls: ['./i4c-outbox.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cOutboxComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly outboxStatuses = [
|
||||||
|
'',
|
||||||
|
'PENDING',
|
||||||
|
'SENDING',
|
||||||
|
'SENT',
|
||||||
|
'FAILED'
|
||||||
|
];
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
|
||||||
|
outboxStatus = '';
|
||||||
|
outboxType = '';
|
||||||
|
outboxSearch = '';
|
||||||
|
outboxDates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
outboxTotal = 0;
|
||||||
|
outboxPageIndex = 0;
|
||||||
|
outboxPageSize = 20;
|
||||||
|
outbox: any[] = [];
|
||||||
|
|
||||||
|
direction = '';
|
||||||
|
failuresOnly = false;
|
||||||
|
logSearch = '';
|
||||||
|
logHttpStatus = '';
|
||||||
|
logResponseCode = '';
|
||||||
|
logDates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
logTotal = 0;
|
||||||
|
logPageIndex = 0;
|
||||||
|
logPageSize = 20;
|
||||||
|
log: any[] = [];
|
||||||
|
logEntry: any = null;
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
busyId: number | null = null;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
activeSection: 'all' | 'outbox' | 'log' = 'all';
|
||||||
|
selectedLogModal: any = null;
|
||||||
|
selectedOutboxModal: any = null;
|
||||||
|
logModalTab: 'req' | 'res' = 'req';
|
||||||
|
copiedKey = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.outboxStatus = this.route.snapshot.queryParamMap.get('status') || '';
|
||||||
|
this.loadOutbox();
|
||||||
|
this.loadLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
setOutboxStatus(s: string): void {
|
||||||
|
this.outboxStatus = s;
|
||||||
|
this.loadOutbox(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDirection(d: string): void {
|
||||||
|
this.direction = d;
|
||||||
|
this.loadLog(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onOutboxPage(e: PageEvent): void {
|
||||||
|
this.outboxPageIndex = e.pageIndex;
|
||||||
|
this.outboxPageSize = e.pageSize;
|
||||||
|
this.loadOutbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
onOutboxDates(range: I4cDateRange): void {
|
||||||
|
this.outboxDates = range;
|
||||||
|
this.loadOutbox(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get outboxHasFilters(): boolean {
|
||||||
|
return !!(this.outboxStatus || this.outboxType || this.outboxSearch || this.outboxDates.preset !== 'ALL');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearOutboxFilters(): void {
|
||||||
|
this.outboxStatus = '';
|
||||||
|
this.outboxType = '';
|
||||||
|
this.outboxSearch = '';
|
||||||
|
this.outboxDates = { ...ALL_DATES };
|
||||||
|
this.loadOutbox(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLogPage(e: PageEvent): void {
|
||||||
|
this.logPageIndex = e.pageIndex;
|
||||||
|
this.logPageSize = e.pageSize;
|
||||||
|
this.loadLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
onLogDates(range: I4cDateRange): void {
|
||||||
|
this.logDates = range;
|
||||||
|
this.loadLog(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get logHasFilters(): boolean {
|
||||||
|
return !!(
|
||||||
|
this.direction ||
|
||||||
|
this.failuresOnly ||
|
||||||
|
this.logSearch ||
|
||||||
|
this.logHttpStatus ||
|
||||||
|
this.logResponseCode ||
|
||||||
|
this.logDates.preset !== 'ALL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLogFilters(): void {
|
||||||
|
this.direction = '';
|
||||||
|
this.failuresOnly = false;
|
||||||
|
this.logSearch = '';
|
||||||
|
this.logHttpStatus = '';
|
||||||
|
this.logResponseCode = '';
|
||||||
|
this.logDates = { ...ALL_DATES };
|
||||||
|
this.loadLog(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
loadOutbox(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.outboxPageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.outbox({
|
||||||
|
status: this.outboxStatus,
|
||||||
|
type: this.outboxType,
|
||||||
|
search: this.outboxSearch.trim(),
|
||||||
|
fromDate: this.outboxDates.fromDate,
|
||||||
|
toDate: this.outboxDates.toDate,
|
||||||
|
offset: this.outboxPageIndex * this.outboxPageSize,
|
||||||
|
limit: this.outboxPageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.outbox = page.pageItems;
|
||||||
|
this.outboxTotal = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLog(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.logPageIndex = 0;
|
||||||
|
}
|
||||||
|
this.i4c
|
||||||
|
.apiLog({
|
||||||
|
direction: this.direction,
|
||||||
|
failures: this.failuresOnly || null,
|
||||||
|
search: this.logSearch.trim(),
|
||||||
|
httpStatus: this.logHttpStatus,
|
||||||
|
responseCode: this.logResponseCode.trim(),
|
||||||
|
fromDate: this.logDates.fromDate,
|
||||||
|
toDate: this.logDates.toDate,
|
||||||
|
offset: this.logPageIndex * this.logPageSize,
|
||||||
|
limit: this.logPageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.log = page.pageItems;
|
||||||
|
this.logTotal = page.totalFilteredRecords;
|
||||||
|
},
|
||||||
|
error: (e) => (this.error = i4cErrorMessage(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(row: any): void {
|
||||||
|
this.busyId = row.id;
|
||||||
|
this.i4c.retryOutbox(row.id).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(r?.sent ? 'Delivered to I4C.' : 'Queued; it will be sent again shortly.', 'OK', {
|
||||||
|
duration: 4000
|
||||||
|
});
|
||||||
|
this.loadOutbox();
|
||||||
|
this.loadLog();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busyId = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openLogModal(row: any): void {
|
||||||
|
this.selectedLogModal = { ...row };
|
||||||
|
this.logModalTab = 'req';
|
||||||
|
this.i4c.apiLogEntry(row.id).subscribe({
|
||||||
|
next: (entry) => (this.selectedLogModal = entry),
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeLogModal(): void {
|
||||||
|
this.selectedLogModal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
openOutboxModal(row: any): void {
|
||||||
|
this.selectedOutboxModal = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeOutboxModal(): void {
|
||||||
|
this.selectedOutboxModal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
copyPayload(text: string, key: string): void {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.copiedKey = key;
|
||||||
|
this.snackBar.open('Payload copied to clipboard.', 'OK', { duration: 2500 });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedKey === key) {
|
||||||
|
this.copiedKey = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A call failed when HTTP says so or I4C/the bank answered with a code other than 00. */
|
||||||
|
isFailedCall(log: any): boolean {
|
||||||
|
return (log?.http_status ?? 0) >= 400 || (!!log?.response_code && log.response_code !== '00');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Successful calls keep a short note in the error column, e.g. how I4C logged in; spell it out. */
|
||||||
|
logNote(text: string): string {
|
||||||
|
const modes: Record<string, string> = {
|
||||||
|
TOKEN: 'I4C logged in with a Bearer token',
|
||||||
|
BASIC: 'I4C logged in with Basic authentication',
|
||||||
|
BODY: 'I4C sent its username and password in the request body'
|
||||||
|
};
|
||||||
|
return (text || '').replace(/auth (TOKEN|BASIC|BODY)/g, (m, mode) => modes[mode] ?? m);
|
||||||
|
}
|
||||||
|
|
||||||
|
pretty(text: string): string {
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(text), null, 2);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(status: number | null): boolean {
|
||||||
|
return status !== null && status !== undefined && status >= 200 && status < 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<!-- Top Action Toolbar -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<a mat-stroked-button routerLink="../" class="back-btn"> <fa-icon icon="arrow-left"></fa-icon> All Complaints </a>
|
||||||
|
|
||||||
|
@if (data) {
|
||||||
|
<div class="ack-badge-strip">
|
||||||
|
<span class="label">Ack No:</span>
|
||||||
|
<b class="val">{{ data.acknowledgement_no }}</b>
|
||||||
|
<button class="copy-chip" (click)="copyAck(data.acknowledgement_no)">
|
||||||
|
<fa-icon icon="copy"></fa-icon> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
@if (canReprocess) {
|
||||||
|
<button mat-flat-button color="primary" (click)="reprocess()" [disabled]="busy">
|
||||||
|
<fa-icon icon="redo" [animation]="busy ? 'spin' : undefined"></fa-icon> Reprocess Now
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button mat-stroked-button (click)="load()" [disabled]="loading">
|
||||||
|
<fa-icon icon="sync" [animation]="loading ? 'spin' : undefined"></fa-icon> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading || busy) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data) {
|
||||||
|
<!-- Main Top Info: Customer Target Account + Processing -->
|
||||||
|
<div class="i4c-grid-2">
|
||||||
|
<!-- Target Account Info -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<span>Complaint Details (From I4C)</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Acknowledgement No.</dt>
|
||||||
|
<dd>
|
||||||
|
<b class="text-accent">{{ data.acknowledgement_no }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>CBS Internal Job ID</dt>
|
||||||
|
<dd>{{ data.job_id }}</dd>
|
||||||
|
|
||||||
|
<dt>Sub-Category</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="cat-chip">{{ data.sub_category }}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Account in this Bank</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ data.payer_account_number }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Complainant Bank</dt>
|
||||||
|
<dd>{{ data.payer_bank }} {{ data.payer_bank_code ? '(' + data.payer_bank_code + ')' : '' }}</dd>
|
||||||
|
|
||||||
|
<dt>Payment Mode / Type</dt>
|
||||||
|
<dd>{{ data.mode_of_payment || '-' }} {{ data.transaction_type ? '/ ' + data.transaction_type : '' }}</dd>
|
||||||
|
|
||||||
|
<dt>Wallet Reference</dt>
|
||||||
|
<dd>{{ data.wallet || 'None' }}</dd>
|
||||||
|
|
||||||
|
<dt>State & District</dt>
|
||||||
|
<dd>{{ data.state }} / {{ data.district }}</dd>
|
||||||
|
|
||||||
|
<dt>Received At</dt>
|
||||||
|
<dd>{{ data.received_at | date: 'dd MMM yyyy, HH:mm:ss' }} from {{ data.source_ip }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Processing State Info -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="tasks"></fa-icon>
|
||||||
|
<span>Automation & Processing Outcome</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Processing Status</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="i4c-badge">{{ data.status }}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Processing Attempts</dt>
|
||||||
|
<dd>{{ data.processing_attempts }}</dd>
|
||||||
|
|
||||||
|
<dt>Processed On</dt>
|
||||||
|
<dd>{{ data.processed_at ? (data.processed_at | date: 'dd MMM yyyy, HH:mm:ss') : 'Not yet processed' }}</dd>
|
||||||
|
|
||||||
|
<dt>Total Amount Held</dt>
|
||||||
|
<dd>
|
||||||
|
<b class="text-accent">₹{{ data.amount_held | number: '1.2-2' }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Outbox Response Status</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="i4c-badge">{{ data.response_status || 'NOT YET SENT' }}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
@if (data.processing_error) {
|
||||||
|
<dt>Last Processing Error</dt>
|
||||||
|
<dd class="err-text">{{ data.processing_error }}</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Incidents Table -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="list-alt"></fa-icon>
|
||||||
|
<span>Reported Incidents & CBS Matching Engine Outcomes</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>RRN</th>
|
||||||
|
<th>Date & Time</th>
|
||||||
|
<th class="num">Amount (₹)</th>
|
||||||
|
<th class="num">Disputed (₹)</th>
|
||||||
|
<th class="num">Layer</th>
|
||||||
|
<th>Match Method</th>
|
||||||
|
<th>Outcome</th>
|
||||||
|
<th class="num">Held Amount</th>
|
||||||
|
<th>Remarks</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (inc of data.incidents; track inc.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ inc.seq_no }}</b>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="font-mono">{{ inc.rrn }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ inc.transaction_date }} {{ inc.transaction_time }}</td>
|
||||||
|
<td class="num">{{ inc.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ inc.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num font-semibold">{{ inc.layer }}</td>
|
||||||
|
<td>
|
||||||
|
<span>{{ methodLabel(inc.match_method) }}</span>
|
||||||
|
@if (inc.matched_txn_id) {
|
||||||
|
<div class="sub">Txn #{{ inc.matched_txn_id }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="outcomeClass(inc.outcome)">{{ inc.outcome || 'PENDING' }}</span>
|
||||||
|
<div class="sub">Code: {{ inc.status_code || '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (holdFor(inc.id); as hold) {
|
||||||
|
₹{{ hold.amount_held | number: '1.2-2' }}
|
||||||
|
@if (hold.shortfall > 0) {
|
||||||
|
<div class="sub err-text">Short: ₹{{ hold.shortfall | number: '1.2-2' }}</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
-
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ inc.remarks || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section 6 Findings -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<span>Entries Reported to I4C (Section 6 Compliance)</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Incident</th>
|
||||||
|
<th>Transaction Type</th>
|
||||||
|
<th class="num">Amount (₹)</th>
|
||||||
|
<th class="num">Disputed (₹)</th>
|
||||||
|
<th>Status Code</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (f of data.findings; track f.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ f.incident_id }}</b>
|
||||||
|
</td>
|
||||||
|
<td>{{ f.txn_type }} ({{ f.txn_type_id }})</td>
|
||||||
|
<td class="num">₹{{ f.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">₹{{ f.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ f.status_code }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="table-action-btn" (click)="openEntry = openEntry === f.id ? null : f.id">
|
||||||
|
<fa-icon [icon]="openEntry === f.id ? 'eye-slash' : 'eye'"></fa-icon>
|
||||||
|
<span>{{ openEntry === f.id ? 'Hide JSON' : 'View JSON' }}</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if (openEntry === f.id) {
|
||||||
|
<tr class="finding-json-row">
|
||||||
|
<td colspan="6" class="finding-json-cell">
|
||||||
|
<div class="finding-json-box">
|
||||||
|
<div class="finding-json-head">
|
||||||
|
<span> <fa-icon icon="code"></fa-icon> Finding #{{ f.id }} Payload </span>
|
||||||
|
<button type="button" class="copy-chip" (click)="copyAck(pretty(f.entry_json))">
|
||||||
|
<fa-icon icon="copy"></fa-icon>
|
||||||
|
<span>Copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(f.entry_json) }}</pre>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="i4c-empty">No section 6 findings reported yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Outbox Delivery -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="history"></fa-icon>
|
||||||
|
<span>Delivery to I4C Outbox Queue</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card__body i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Created At</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="num">Attempts</th>
|
||||||
|
<th>HTTP Code</th>
|
||||||
|
<th>Sent Timestamp</th>
|
||||||
|
<th>Error</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (o of data.outbox; track o.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge">{{ o.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ o.attempts }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="font-mono">{{ o.http_status ?? '-' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||||
|
<td class="err-text">{{ o.last_error || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
@if (o.status === 'FAILED') {
|
||||||
|
<button mat-stroked-button color="warn" (click)="retry(o.id)" [disabled]="busy">
|
||||||
|
<fa-icon icon="redo"></fa-icon> Retry Send
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="i4c-empty">No response written to outbox yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Raw Payloads Accordion -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="code"></fa-icon>
|
||||||
|
<span>Cryptographic & Raw Payloads Inspection</span>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<button mat-button (click)="showRaw = !showRaw">
|
||||||
|
<fa-icon [icon]="showRaw ? 'eye-slash' : 'eye'"></fa-icon>
|
||||||
|
<span>{{ showRaw ? 'Hide Payloads' : 'Inspect Payloads' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (showRaw) {
|
||||||
|
<div class="i4c-card__body payloads">
|
||||||
|
<div>
|
||||||
|
<div class="sub-title">Received from I4C Gateway (Sensitive credentials and PAN masked)</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(data.raw_payload) }}</pre>
|
||||||
|
</div>
|
||||||
|
@for (o of data.outbox; track o.id) {
|
||||||
|
<div>
|
||||||
|
<div class="sub-title">Response #{{ o.id }} Sent to I4C</div>
|
||||||
|
<pre class="i4c-json">{{ pretty(o.payload) }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.ack-badge-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.val {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-accent {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payloads {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One complaint from I4C end to end: what I4C sent, what the bank matched in its books, the holds it placed, every
|
||||||
|
* entry it reported back, and each attempt to deliver that report.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-request-detail',
|
||||||
|
templateUrl: './i4c-request-detail.component.html',
|
||||||
|
styleUrls: ['./i4c-request-detail.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
RouterLink,
|
||||||
|
MatButtonModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cRequestDetailComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
data: any = null;
|
||||||
|
loading = false;
|
||||||
|
busy = false;
|
||||||
|
error = '';
|
||||||
|
showRaw = false;
|
||||||
|
openEntry: number | null = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
get id(): number {
|
||||||
|
return Number(this.route.snapshot.paramMap.get('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c.request(this.id).subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
this.data = d;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get canReprocess(): boolean {
|
||||||
|
return this.data?.status === 'RETRY' || this.data?.status === 'RECEIVED';
|
||||||
|
}
|
||||||
|
|
||||||
|
reprocess(): void {
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.reprocess(this.id).subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
this.data = d;
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open('Complaint processed again.', 'OK', { duration: 3000 });
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retry(outboxId: number): void {
|
||||||
|
this.busy = true;
|
||||||
|
this.i4c.retryOutbox(outboxId).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(
|
||||||
|
r?.sent ? 'Response delivered to I4C.' : 'Retry queued; it will be sent again shortly.',
|
||||||
|
'OK',
|
||||||
|
{
|
||||||
|
duration: 4000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findingsFor(incidentId: number): any[] {
|
||||||
|
return (this.data?.findings || []).filter((f: any) => f.incident_id === incidentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
holdFor(incidentId: number): any {
|
||||||
|
return (this.data?.holds || []).find((h: any) => h.incident_id === incidentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
pretty(json: string): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(json), null, 2);
|
||||||
|
} catch {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outcomeClass(outcome: string): string {
|
||||||
|
switch (outcome) {
|
||||||
|
case 'HELD':
|
||||||
|
case 'TRACED':
|
||||||
|
return 'i4c-badge--green';
|
||||||
|
case 'PARTIALLY_HELD':
|
||||||
|
case 'PROSPECTIVE_HOLD':
|
||||||
|
return 'i4c-badge--amber';
|
||||||
|
case 'NOT_FOUND':
|
||||||
|
case 'NO_MATCH':
|
||||||
|
case 'NOT_HOLDABLE':
|
||||||
|
case 'ERROR':
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
methodLabel(method: string): string {
|
||||||
|
switch (method) {
|
||||||
|
case 'RRN_FUND_TRANSFER':
|
||||||
|
return 'RRN on outward transfer';
|
||||||
|
case 'RRN_REFERENCE_TEXT':
|
||||||
|
return 'RRN in transaction narration';
|
||||||
|
case 'AMOUNT_DATE':
|
||||||
|
return 'Amount + date (no RRN in CBS)';
|
||||||
|
case 'NONE':
|
||||||
|
return 'Not matched';
|
||||||
|
default:
|
||||||
|
return method || '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copyAck(text: string): void {
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<!-- Modern Toolbar & Filter Chips -->
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="status === ''" (click)="setStatus('')">
|
||||||
|
<span>All Complaints</span>
|
||||||
|
</button>
|
||||||
|
@for (s of statuses; track s) {
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="status === s" (click)="setStatus(s)">
|
||||||
|
<span>{{ s }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||||
|
<mat-label>Search acknowledgement / account / job id</mat-label>
|
||||||
|
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" placeholder="Type & hit enter..." />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" class="search-btn" (click)="load(true)">
|
||||||
|
<fa-icon icon="search"></fa-icon> Search
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="count-tag">{{ total }} records</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Sub-category</mat-label>
|
||||||
|
<input matInput [(ngModel)]="subCategory" (keyup.enter)="load(true)" (blur)="load(true)" placeholder="e.g. UPI" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Layer</mat-label>
|
||||||
|
<mat-select [(ngModel)]="layer" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">All layers</mat-option>
|
||||||
|
<mat-option value="0">Layer 0 (victim's account)</mat-option>
|
||||||
|
<mat-option value="1">Layer 1+ (money landed here)</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Response to I4C</mat-label>
|
||||||
|
<mat-select [(ngModel)]="response" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="SENT">Sent</mat-option>
|
||||||
|
<mat-option value="PENDING">Pending / sending</mat-option>
|
||||||
|
<mat-option value="FAILED">Failed</mat-option>
|
||||||
|
<mat-option value="NONE">Not yet created</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mifosx-i4c-date-range label="Received" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Complaints Data Table -->
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<span>I4C-MHA Cyber Fraud Complaints Log</span>
|
||||||
|
<span class="head-hint">Click any complaint to open modern detail popup window</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Received</th>
|
||||||
|
<th>Acknowledgement No.</th>
|
||||||
|
<th>Sub-Category</th>
|
||||||
|
<th>Target Account</th>
|
||||||
|
<th class="num">Layer</th>
|
||||||
|
<th class="num">Incidents</th>
|
||||||
|
<th class="num">Held (₹)</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>I4C Response</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (row of rows; track row.id) {
|
||||||
|
<tr class="clickable" (click)="openModal(row)">
|
||||||
|
<td>
|
||||||
|
<span class="date-main">{{ row.received_at | date: 'dd MMM yyyy' }}</span>
|
||||||
|
<div class="sub">{{ row.received_at | date: 'HH:mm:ss' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="ack-cell">
|
||||||
|
<span class="ack-no">{{ row.acknowledgement_no }}</span>
|
||||||
|
<button
|
||||||
|
class="copy-chip"
|
||||||
|
(click)="copyAck(row.acknowledgement_no, 'ack-' + row.id, $event)"
|
||||||
|
title="Copy Ack No."
|
||||||
|
>
|
||||||
|
<fa-icon icon="copy"></fa-icon>
|
||||||
|
<span>{{ copiedId === 'ack-' + row.id ? 'Copied' : 'Copy' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="sub">Job: {{ row.job_id }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="cat-pill">{{ row.sub_category || 'N/A' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ row.payer_account_number }}</b>
|
||||||
|
</td>
|
||||||
|
<td class="num font-semibold">{{ row.max_layer }}</td>
|
||||||
|
<td class="num">{{ row.incidents }}</td>
|
||||||
|
<td class="num font-bold">₹{{ row.amount_held | number: '1.2-2' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="statusClass(row.status)">{{ row.status }}</span>
|
||||||
|
@if (row.processing_error) {
|
||||||
|
<div class="sub err-text" [title]="row.processing_error">{{ row.processing_error }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="responseClass(row.response_status)">
|
||||||
|
{{ row.response_status || 'NOT YET' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="i4c-empty">
|
||||||
|
<div class="empty-icon"><fa-icon icon="inbox"></fa-icon></div>
|
||||||
|
<div>No complaints from I4C matched your query.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Complaints pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: COMPLAINT DETAILS MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (selectedComplaint) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeModal()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span>Ack #{{ selectedComplaint.acknowledgement_no }}</span>
|
||||||
|
<span class="i4c-badge" [class]="statusClass(selectedComplaint.status)">{{
|
||||||
|
selectedComplaint.status
|
||||||
|
}}</span>
|
||||||
|
<button class="copy-chip" (click)="copyAck(selectedComplaint.acknowledgement_no, 'modal-ack')">
|
||||||
|
<fa-icon icon="copy"></fa-icon>
|
||||||
|
<span>{{ copiedId === 'modal-ack' ? 'Copied' : 'Copy Ack' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
Job ID: {{ selectedComplaint.job_id }} • Received:
|
||||||
|
{{ selectedComplaint.received_at | date: 'dd MMM yyyy, HH:mm:ss' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__actions">
|
||||||
|
<button class="modal-close-btn" (click)="closeModal()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Tabs inside the Modal -->
|
||||||
|
<div class="i4c-modal-tabs">
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'overview'" (click)="modalTab = 'overview'">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> Overview & Account
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="modal-tab"
|
||||||
|
[class.modal-tab--active]="modalTab === 'incidents'"
|
||||||
|
(click)="modalTab = 'incidents'"
|
||||||
|
>
|
||||||
|
<fa-icon icon="tasks"></fa-icon> Incidents ({{
|
||||||
|
selectedComplaint.incidents?.length || selectedComplaint.incidents || 0
|
||||||
|
}})
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'findings'" (click)="modalTab = 'findings'">
|
||||||
|
<fa-icon icon="list-alt"></fa-icon> Section 6 Findings ({{ selectedComplaint.findings?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'outbox'" (click)="modalTab = 'outbox'">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> Outbox Responses ({{ selectedComplaint.outbox?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'payloads'" (click)="modalTab = 'payloads'">
|
||||||
|
<fa-icon icon="code"></fa-icon> Raw JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Body -->
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
@if (detailLoading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (detailError) {
|
||||||
|
<div class="i4c-error">{{ detailError }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 1: OVERVIEW -->
|
||||||
|
@if (modalTab === 'overview') {
|
||||||
|
<div class="i4c-grid-2">
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="user-shield"></fa-icon> Target Customer Account</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Account Number</dt>
|
||||||
|
<dd>
|
||||||
|
<b>{{ selectedComplaint.payer_account_number }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Sub-Category</dt>
|
||||||
|
<dd>{{ selectedComplaint.sub_category }}</dd>
|
||||||
|
|
||||||
|
<dt>Complainant Bank</dt>
|
||||||
|
<dd>
|
||||||
|
{{ selectedComplaint.payer_bank }}
|
||||||
|
{{ selectedComplaint.payer_bank_code ? '(' + selectedComplaint.payer_bank_code + ')' : '' }}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Payment Mode</dt>
|
||||||
|
<dd>
|
||||||
|
{{ selectedComplaint.mode_of_payment || 'N/A' }}
|
||||||
|
{{ selectedComplaint.transaction_type ? '/ ' + selectedComplaint.transaction_type : '' }}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Wallet Reference</dt>
|
||||||
|
<dd>{{ selectedComplaint.wallet || 'None' }}</dd>
|
||||||
|
|
||||||
|
<dt>State / District</dt>
|
||||||
|
<dd>{{ selectedComplaint.state }} / {{ selectedComplaint.district }}</dd>
|
||||||
|
|
||||||
|
<dt>Source IP</dt>
|
||||||
|
<dd>{{ selectedComplaint.source_ip || '-' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="sync"></fa-icon> Processing Status</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Processing Status</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="i4c-badge" [class]="statusClass(selectedComplaint.status)">{{
|
||||||
|
selectedComplaint.status
|
||||||
|
}}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Processing Attempts</dt>
|
||||||
|
<dd>{{ selectedComplaint.processing_attempts ?? 1 }}</dd>
|
||||||
|
|
||||||
|
<dt>Processed Timestamp</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
selectedComplaint.processed_at
|
||||||
|
? (selectedComplaint.processed_at | date: 'dd MMM yyyy, HH:mm:ss')
|
||||||
|
: 'Pending processing'
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Total Held Amount</dt>
|
||||||
|
<dd>
|
||||||
|
<b class="amount-accent">₹{{ selectedComplaint.amount_held | number: '1.2-2' }}</b>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Response to I4C</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="i4c-badge" [class]="responseClass(selectedComplaint.response_status)">
|
||||||
|
{{ selectedComplaint.response_status || 'NOT YET SENT' }}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
@if (selectedComplaint.processing_error) {
|
||||||
|
<dt>Last Error</dt>
|
||||||
|
<dd class="err-text">{{ selectedComplaint.processing_error }}</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 2: INCIDENTS -->
|
||||||
|
@if (modalTab === 'incidents') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>RRN</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th class="num">Amount (₹)</th>
|
||||||
|
<th class="num">Disputed (₹)</th>
|
||||||
|
<th class="num">Layer</th>
|
||||||
|
<th>Matched By</th>
|
||||||
|
<th>Outcome</th>
|
||||||
|
<th class="num">Held (₹)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (inc of selectedComplaint.incidents; track inc.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<b>{{ inc.seq_no }}</b>
|
||||||
|
</td>
|
||||||
|
<td>{{ inc.rrn }}</td>
|
||||||
|
<td>{{ inc.transaction_date }} {{ inc.transaction_time }}</td>
|
||||||
|
<td class="num">{{ inc.amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ inc.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ inc.layer }}</td>
|
||||||
|
<td>
|
||||||
|
<span>{{ inc.match_method || 'EXACT' }}</span>
|
||||||
|
@if (inc.matched_txn_id) {
|
||||||
|
<div class="sub">Txn #{{ inc.matched_txn_id }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="outcomeClass(inc.outcome)">{{
|
||||||
|
inc.outcome || 'PENDING'
|
||||||
|
}}</span>
|
||||||
|
<div class="sub">Code: {{ inc.status_code || '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
@if (holdFor(inc.id); as hold) {
|
||||||
|
₹{{ hold.amount_held | number: '1.2-2' }}
|
||||||
|
@if (hold.shortfall > 0) {
|
||||||
|
<div class="sub err-text">Short ₹{{ hold.shortfall | number: '1.2-2' }}</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
-
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="i4c-empty">No incident details found.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 3: FINDINGS (SECTION 6) -->
|
||||||
|
@if (modalTab === 'findings') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Incident ID</th>
|
||||||
|
<th>Transaction Type</th>
|
||||||
|
<th class="num">Amount (₹)</th>
|
||||||
|
<th class="num">Disputed (₹)</th>
|
||||||
|
<th>Status Code</th>
|
||||||
|
<th>Raw JSON</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (f of selectedComplaint.findings; track f.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ f.incident_id }}</td>
|
||||||
|
<td>{{ f.txn_type }} ({{ f.txn_type_id }})</td>
|
||||||
|
<td class="num">₹{{ f.amount | number: '1.2-2' }}</td>
|
||||||
|
@if (f.disputed_amount === null || f.disputed_amount === undefined) {
|
||||||
|
<td
|
||||||
|
class="num muted-cell"
|
||||||
|
title="The specification has no disputed amount in a Transaction Put on Hold entry"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</td>
|
||||||
|
} @else {
|
||||||
|
<td class="num">₹{{ f.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
}
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class]="statusCodeClass(f.status_code)"
|
||||||
|
[title]="statusCodeMeaning(f.status_code)"
|
||||||
|
>
|
||||||
|
{{ f.status_code }} · {{ statusCodeMeaning(f.status_code) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="table-action-btn" (click)="toggleFindingJson(f.id)">
|
||||||
|
{{ openFindingJsonId === f.id ? 'Hide JSON' : 'View JSON' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if (openFindingJsonId === f.id) {
|
||||||
|
<tr class="finding-json-row">
|
||||||
|
<td colspan="6" class="finding-json-cell">
|
||||||
|
<div class="finding-json-box">
|
||||||
|
<div class="finding-json-head">
|
||||||
|
<span> <fa-icon icon="code"></fa-icon> Finding #{{ f.id }} Payload </span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="copy-chip"
|
||||||
|
(click)="copyAck(prettyJson(f.entry_json), 'finding-' + f.id, $event)"
|
||||||
|
>
|
||||||
|
<fa-icon icon="copy"></fa-icon>
|
||||||
|
<span>{{ copiedId === 'finding-' + f.id ? 'Copied!' : 'Copy' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ prettyJson(f.entry_json) }}</pre>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="i4c-empty">No section 6 findings reported yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 4: OUTBOX RESPONSES -->
|
||||||
|
@if (modalTab === 'outbox') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Created</th>
|
||||||
|
<th title="ACK: separate acknowledgement callback. RESPONSE: the Section 6 findings.">Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="num">Attempts</th>
|
||||||
|
<th>HTTP Code</th>
|
||||||
|
<th>Sent Time</th>
|
||||||
|
<th>Error</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (o of selectedComplaint.outbox; track o.id) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ o.created_at | date: 'dd MMM, HH:mm:ss' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class.i4c-badge--blue]="o.message_type === 'ACK'">{{
|
||||||
|
o.message_type === 'ACK' ? 'ACK' : 'RESPONSE'
|
||||||
|
}}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="i4c-badge" [class]="responseClass(o.status)">{{ o.status }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="num">{{ o.attempts }}</td>
|
||||||
|
<td>{{ o.http_status ?? '-' }}</td>
|
||||||
|
<td>{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}</td>
|
||||||
|
<td class="err-text">{{ o.last_error }}</td>
|
||||||
|
<td>
|
||||||
|
@if (o.status === 'FAILED') {
|
||||||
|
<button
|
||||||
|
class="table-action-btn"
|
||||||
|
(click)="retryOutboxItem(o.id)"
|
||||||
|
[disabled]="retryingOutboxId === o.id"
|
||||||
|
>
|
||||||
|
<fa-icon
|
||||||
|
icon="redo"
|
||||||
|
[animation]="retryingOutboxId === o.id ? 'spin' : undefined"
|
||||||
|
></fa-icon>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="i4c-empty">No outbox delivery records yet.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- TAB 5: RAW PAYLOADS -->
|
||||||
|
@if (modalTab === 'payloads') {
|
||||||
|
<div class="payloads-view">
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<span>Payload Received from I4C Gateway (Masked)</span>
|
||||||
|
<button class="copy-chip" (click)="copyAck(prettyJson(selectedComplaint.raw_payload), 'payload-in')">
|
||||||
|
<fa-icon icon="copy"></fa-icon> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ prettyJson(selectedComplaint.raw_payload) }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (o of selectedComplaint.outbox; track o.id) {
|
||||||
|
<div class="payload-box">
|
||||||
|
<div class="payload-box__title">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon>
|
||||||
|
<span>Response #{{ o.id }} Delivered to I4C</span>
|
||||||
|
<button class="copy-chip" (click)="copyAck(prettyJson(o.payload), 'payload-out-' + o.id)">
|
||||||
|
<fa-icon icon="copy"></fa-icon> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="i4c-json">{{ prettyJson(o.payload) }}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
@if (selectedComplaint.status === 'RETRY' || selectedComplaint.status === 'PROCESSING') {
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="primary"
|
||||||
|
(click)="reprocessModal(selectedComplaint.id)"
|
||||||
|
[disabled]="reprocessing"
|
||||||
|
>
|
||||||
|
<fa-icon icon="redo" [animation]="reprocessing ? 'spin' : undefined"></fa-icon> Process Now
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button mat-stroked-button (click)="openFullPage(selectedComplaint.id)">
|
||||||
|
<fa-icon icon="external-link-alt"></fa-icon> Open in Full Page
|
||||||
|
</button>
|
||||||
|
<button class="footer-btn-close" (click)="closeModal()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ack-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.ack-no {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-main {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 10.5px;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-accent {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payloads-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payload-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-json-cell {
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
background: #0b1329 !important;
|
||||||
|
border-bottom: 2px solid #1e293b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finding-json-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.finding-json-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shown at full length: the popup body is the one scroll area, so nothing hides behind the footer. */
|
||||||
|
pre.i4c-json {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 6px 0 16px;
|
||||||
|
box-shadow: none;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-cell {
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
|
||||||
|
/** Every complaint I4C has pushed to the bank, newest first. */
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-requests',
|
||||||
|
templateUrl: './i4c-requests.component.html',
|
||||||
|
styleUrls: ['./i4c-requests.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cRequestsComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly statuses = [
|
||||||
|
'RECEIVED',
|
||||||
|
'PROCESSING',
|
||||||
|
'RETRY',
|
||||||
|
'PROCESSED'
|
||||||
|
];
|
||||||
|
rows: any[] = [];
|
||||||
|
status = '';
|
||||||
|
search = '';
|
||||||
|
subCategory = '';
|
||||||
|
layer = '';
|
||||||
|
response = '';
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
// Popup Window Modal state
|
||||||
|
selectedComplaint: any = null;
|
||||||
|
detailLoading = false;
|
||||||
|
detailError = '';
|
||||||
|
modalTab: 'overview' | 'incidents' | 'findings' | 'outbox' | 'payloads' = 'overview';
|
||||||
|
openFindingJsonId: number | null = null;
|
||||||
|
reprocessing = false;
|
||||||
|
retryingOutboxId: number | null = null;
|
||||||
|
copiedId = '';
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.status = this.route.snapshot.queryParamMap.get('status') || '';
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.requests({
|
||||||
|
status: this.status,
|
||||||
|
search: this.search.trim(),
|
||||||
|
subCategory: this.subCategory.trim(),
|
||||||
|
layer: this.layer,
|
||||||
|
response: this.response,
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(s: string): void {
|
||||||
|
this.status = s;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(
|
||||||
|
this.status ||
|
||||||
|
this.search ||
|
||||||
|
this.subCategory ||
|
||||||
|
this.layer ||
|
||||||
|
this.response ||
|
||||||
|
this.dates.preset !== 'ALL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters(): void {
|
||||||
|
this.status = '';
|
||||||
|
this.search = '';
|
||||||
|
this.subCategory = '';
|
||||||
|
this.layer = '';
|
||||||
|
this.response = '';
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open in Modern Popup Window Modal
|
||||||
|
openModal(row: any): void {
|
||||||
|
this.detailLoading = true;
|
||||||
|
this.detailError = '';
|
||||||
|
this.modalTab = 'overview';
|
||||||
|
this.openFindingJsonId = null;
|
||||||
|
this.selectedComplaint = { ...row };
|
||||||
|
|
||||||
|
this.i4c.request(row.id).subscribe({
|
||||||
|
next: (full) => {
|
||||||
|
// Keep the list figures (held amount, response status) if the detail lacks them.
|
||||||
|
this.selectedComplaint = { ...row, ...full };
|
||||||
|
this.detailLoading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.detailError = i4cErrorMessage(e);
|
||||||
|
this.detailLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal(): void {
|
||||||
|
this.selectedComplaint = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleFindingJson(id: number): void {
|
||||||
|
this.openFindingJsonId = this.openFindingJsonId === id ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open in Full Page view
|
||||||
|
openFullPage(id: number): void {
|
||||||
|
this.closeModal();
|
||||||
|
this.router.navigate([id], { relativeTo: this.route });
|
||||||
|
}
|
||||||
|
|
||||||
|
reprocessModal(id: number): void {
|
||||||
|
this.reprocessing = true;
|
||||||
|
this.i4c.reprocess(id).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.reprocessing = false;
|
||||||
|
this.snackBar.open('Complaint queued for reprocessing.', 'OK', { duration: 4000 });
|
||||||
|
this.openModal({ id });
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.reprocessing = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
retryOutboxItem(id: number): void {
|
||||||
|
this.retryingOutboxId = id;
|
||||||
|
this.i4c.retryOutbox(id).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.retryingOutboxId = null;
|
||||||
|
this.snackBar.open(r?.sent ? 'Delivered to I4C.' : 'Queued for retry.', 'OK', { duration: 4000 });
|
||||||
|
if (this.selectedComplaint?.id) {
|
||||||
|
this.openModal({ id: this.selectedComplaint.id });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.retryingOutboxId = null;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyAck(text: string, id: string, event?: Event): void {
|
||||||
|
if (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
this.copiedId = id;
|
||||||
|
this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.copiedId === id) {
|
||||||
|
this.copiedId = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a section 6 status_code means, as I4C reads it. */
|
||||||
|
statusCodeMeaning(code: string): string {
|
||||||
|
switch (code) {
|
||||||
|
case '00':
|
||||||
|
return 'Success';
|
||||||
|
case '01':
|
||||||
|
return 'Record not found';
|
||||||
|
case '02':
|
||||||
|
return 'Invalid RRN';
|
||||||
|
case '70010':
|
||||||
|
return 'Not processed';
|
||||||
|
default:
|
||||||
|
return code ? 'Code ' + code : 'No code';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCodeClass(code: string): string {
|
||||||
|
return code === '00' ? 'i4c-badge--green' : code === '01' || code === '02' ? 'i4c-badge--amber' : 'i4c-badge--red';
|
||||||
|
}
|
||||||
|
|
||||||
|
prettyJson(val: any): string {
|
||||||
|
if (!val) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = typeof val === 'string' ? JSON.parse(val) : val;
|
||||||
|
return JSON.stringify(parsed, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'PROCESSED':
|
||||||
|
return 'i4c-badge--green';
|
||||||
|
case 'RETRY':
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
case 'PROCESSING':
|
||||||
|
case 'RECEIVED':
|
||||||
|
return 'i4c-badge--amber';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responseClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'SENT':
|
||||||
|
return 'i4c-badge--green';
|
||||||
|
case 'FAILED':
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
case 'PENDING':
|
||||||
|
case 'SENDING':
|
||||||
|
return 'i4c-badge--amber';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
outcomeClass(outcome: string): string {
|
||||||
|
switch (outcome) {
|
||||||
|
case 'HELD':
|
||||||
|
return 'i4c-badge--green';
|
||||||
|
case 'SHORT':
|
||||||
|
return 'i4c-badge--amber';
|
||||||
|
case 'NOT_FOUND':
|
||||||
|
case 'FAILED':
|
||||||
|
return 'i4c-badge--red';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
holdFor(incidentId: number): any {
|
||||||
|
return (this.selectedComplaint?.holds || []).find((h: any) => h.incident_id === incidentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-page">
|
||||||
|
<div class="i4c-toolbar">
|
||||||
|
<div class="i4c-filter-chips">
|
||||||
|
<button
|
||||||
|
class="filter-chip"
|
||||||
|
[class.filter-chip--active]="role === 'BENEFICIARY_BANK'"
|
||||||
|
(click)="setRole('BENEFICIARY_BANK')"
|
||||||
|
>
|
||||||
|
<fa-icon icon="hand-holding-usd"></fa-icon> <span>Money held here (beneficiary bank)</span>
|
||||||
|
</button>
|
||||||
|
<button class="filter-chip" [class.filter-chip--active]="role === 'VICTIM_BANK'" (click)="setRole('VICTIM_BANK')">
|
||||||
|
<fa-icon icon="user"></fa-icon> <span>Our customer is the victim (victim bank)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="i4c-toolbar__spacer"></span>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Layer 0 ack no.</mat-label>
|
||||||
|
<input matInput [(ngModel)]="victimAck" (keyup.enter)="openVictimCase()" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-stroked-button (click)="openVictimCase()" [disabled]="!victimAck.trim()">
|
||||||
|
<fa-icon icon="plus"></fa-icon> Open victim-bank case
|
||||||
|
</button>
|
||||||
|
<span class="count-tag">{{ total }} records</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-filter-bar">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-input">
|
||||||
|
<mat-label>MR id / ack / victim / account / UTR</mat-label>
|
||||||
|
<input matInput [(ngModel)]="search" (keyup.enter)="load(true)" (blur)="load(true)" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Status</mat-label>
|
||||||
|
<mat-select [(ngModel)]="status" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="OPEN">All open</mat-option>
|
||||||
|
<mat-option value="">All</mat-option>
|
||||||
|
@for (s of statuses; track s.value) {
|
||||||
|
<mat-option [value]="s.value">{{ s.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (role === 'BENEFICIARY_BANK') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Bank action</mat-label>
|
||||||
|
<mat-select [(ngModel)]="bankAction" (selectionChange)="load(true)">
|
||||||
|
<mat-option value="">Any</mat-option>
|
||||||
|
<mat-option value="PENDING">Pending</mat-option>
|
||||||
|
@for (a of bankActions; track a.value) {
|
||||||
|
<mat-option [value]="a.value">{{ a.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
<mifosx-i4c-date-range label="Opened" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range>
|
||||||
|
<button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
|
||||||
|
<fa-icon icon="times"></fa-icon> Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading) {
|
||||||
|
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||||
|
}
|
||||||
|
@if (error) {
|
||||||
|
<div class="i4c-error">
|
||||||
|
<fa-icon icon="exclamation-circle"></fa-icon> <span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head">
|
||||||
|
<fa-icon icon="hand-holding-usd"></fa-icon>
|
||||||
|
<span>MRM Money Restoration</span>
|
||||||
|
<span class="head-hint"
|
||||||
|
>Assess a hold for MRM from the hold (Holds Register → hold → GRM / MRM). Click a row to work on it.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-table-wrap">
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Opened</th>
|
||||||
|
<th>Ack / MR id</th>
|
||||||
|
@if (role === 'BENEFICIARY_BANK') {
|
||||||
|
<th>Account on hold</th>
|
||||||
|
<th>Basis</th>
|
||||||
|
<th class="num">Restorable (₹)</th>
|
||||||
|
}
|
||||||
|
<th>Victim</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Money</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (r of rows; track r.id) {
|
||||||
|
<tr class="clickable" (click)="open(r.id)">
|
||||||
|
<td>
|
||||||
|
{{ r.created_at | date: 'dd MMM yyyy' }}
|
||||||
|
<div class="sub">{{ r.created_by }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<b>{{ r.acknowledgement_no }}</b>
|
||||||
|
<div class="sub">{{ r.mrm_request_no || 'no MR id yet' }}</div>
|
||||||
|
</td>
|
||||||
|
@if (role === 'BENEFICIARY_BANK') {
|
||||||
|
<td>
|
||||||
|
{{ r.hold_account_no }}
|
||||||
|
<div class="sub">{{ r.client_name }} · held ₹{{ r.amount_held | number: '1.2-2' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ label(bases, r.basis) }}</td>
|
||||||
|
<td class="num">{{ r.restorable_amount | number: '1.2-2' }}</td>
|
||||||
|
}
|
||||||
|
<td>
|
||||||
|
{{ r.victim_name || '-' }}
|
||||||
|
<div class="sub">{{ r.victim_account_no }} {{ r.victim_ifsc }}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="i4c-badge"
|
||||||
|
[class.i4c-badge--green]="r.status === 'RESTORED' || r.status === 'RECEIVED'"
|
||||||
|
[class.i4c-badge--amber]="
|
||||||
|
r.status === 'IN_POOL' || r.status === 'TRANSFER_PENDING' || r.status === 'AWAITING_APPROVAL'
|
||||||
|
"
|
||||||
|
>{{ label(statuses, r.status) }}</span
|
||||||
|
>
|
||||||
|
@if (r.bank_action && r.bank_action !== 'PENDING') {
|
||||||
|
<div class="sub">{{ r.bank_action }}</div>
|
||||||
|
}
|
||||||
|
@if (r.transfer_error) {
|
||||||
|
<div class="sub err-text">{{ r.transfer_error }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (r.role === 'VICTIM_BANK') {
|
||||||
|
{{
|
||||||
|
r.received_amount
|
||||||
|
? '₹' + (r.received_amount | number: '1.2-2') + ' received'
|
||||||
|
: r.victim_status || '-'
|
||||||
|
}}
|
||||||
|
} @else {
|
||||||
|
{{ r.restored_amount ? '₹' + (r.restored_amount | number: '1.2-2') : '-' }}
|
||||||
|
<div class="sub">{{ r.utr || r.transfer_status || '' }}{{ r.mrm_updated ? ' · on MRM' : '' }}</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
} @empty {
|
||||||
|
<tr>
|
||||||
|
<td [attr.colspan]="role === 'BENEFICIARY_BANK' ? 8 : 5" class="i4c-empty">
|
||||||
|
No restorations match these filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<mat-paginator
|
||||||
|
class="i4c-paginator"
|
||||||
|
[length]="total"
|
||||||
|
[pageIndex]="pageIndex"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="pageSizes"
|
||||||
|
(page)="onPage($event)"
|
||||||
|
showFirstLastButtons
|
||||||
|
aria-label="Restoration pages"
|
||||||
|
></mat-paginator>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selected; as r) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="close()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="hand-holding-usd"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<div class="modal-title-row">
|
||||||
|
<span
|
||||||
|
>{{ isVictim ? 'Victim-bank case' : 'Restoration' }} #{{ r.id }} · Ack
|
||||||
|
{{ r.acknowledgement_no }}</span
|
||||||
|
>
|
||||||
|
<span class="i4c-badge">{{ label(statuses, r.status) }}</span>
|
||||||
|
@if (r.mrm_updated) {
|
||||||
|
<span class="i4c-badge i4c-badge--green">ON MRM</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__sub">
|
||||||
|
@if (isVictim) {
|
||||||
|
Our customer's account {{ r.victim_account_no }}
|
||||||
|
} @else {
|
||||||
|
Held on {{ r.hold_account_no }} ({{ r.client_name }}): ₹{{ r.amount_held | number: '1.2-2' }} ·
|
||||||
|
disputed ₹{{ r.disputed_amount | number: '1.2-2' }}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-modal-header__actions">
|
||||||
|
<button class="modal-close-btn" (click)="close()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-tabs">
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'workflow'" (click)="tab = 'workflow'">
|
||||||
|
<fa-icon icon="tasks"></fa-icon> Next step
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'details'" (click)="tab = 'details'">
|
||||||
|
<fa-icon icon="info-circle"></fa-icon> Details
|
||||||
|
</button>
|
||||||
|
<button class="modal-tab" [class.modal-tab--active]="tab === 'documents'" (click)="tab = 'documents'">
|
||||||
|
<fa-icon icon="paperclip"></fa-icon> Documents ({{ r.documents?.length || 0 }})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
@if (tab === 'workflow') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
@if (!isVictim) {
|
||||||
|
@if (r.status === 'ASSESSED' || r.status === 'REQUESTED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">1. Assessment (what the bank updates on MRM)</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Restoration</mat-label>
|
||||||
|
<mat-select [(ngModel)]="assessment.basis">
|
||||||
|
@for (b of bases; track b.value) {
|
||||||
|
<mat-option [value]="b.value">{{ b.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (possible) {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Restorable amount (₹)</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="assessment.restorableAmount" />
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-stroked-button (click)="loadProRata()">
|
||||||
|
<fa-icon icon="calculator"></fa-icon> Pro-rata share
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (proRata) {
|
||||||
|
<table class="i4c-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Complaint (ack)</th>
|
||||||
|
<th class="num">Disputed</th>
|
||||||
|
<th class="num">Held</th>
|
||||||
|
<th class="num">Pro-rata share</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (h of proRata.holds; track h.id) {
|
||||||
|
<tr [class.highlight]="h.id === r.hold_id">
|
||||||
|
<td>{{ h.acknowledgement_no }}{{ h.id === r.hold_id ? ' (this one)' : '' }}</td>
|
||||||
|
<td class="num">{{ h.disputed_amount | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">{{ h.amount_held | number: '1.2-2' }}</td>
|
||||||
|
<td class="num">
|
||||||
|
<b>{{ h.share | number: '1.2-2' }}</b>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="sub">
|
||||||
|
₹{{ proRata.amount | number: '1.2-2' }} held on {{ proRata.accountNo }} split by disputed
|
||||||
|
amount (total ₹{{ proRata.totalDisputed | number: '1.2-2' }}).
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<mat-form-field appearance="outline" class="step__wide">
|
||||||
|
<mat-label>Remarks{{ possible ? '' : ' (why not possible) *' }}</mat-label>
|
||||||
|
<input matInput [(ngModel)]="assessment.remarks" maxlength="1000" />
|
||||||
|
</mat-form-field>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-stroked-button color="primary" (click)="saveAssessment()" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Save assessment
|
||||||
|
</button>
|
||||||
|
@if (r.status === 'ASSESSED' && !r.mrm_updated) {
|
||||||
|
<button mat-button (click)="markMrm()" [disabled]="busy">Entered on MRM</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'ASSESSED' || r.status === 'REQUESTED' || r.status === 'IN_POOL') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
{{
|
||||||
|
r.status === 'IN_POOL'
|
||||||
|
? 'Refund account (correct it if the victim bank referred back)'
|
||||||
|
: "2. Victim's MRM request, as the IO sent it"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="step__row">
|
||||||
|
@if (r.status !== 'IN_POOL') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>MRM request id *</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="request['mrmRequestNo']"
|
||||||
|
maxlength="40"
|
||||||
|
placeholder="MR20251208000003"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Victim name *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['victimName']" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Refund account no. *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['victimAccountNo']" maxlength="40" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>IFSC (blank = this bank)</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['victimIfsc']" maxlength="11" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Victim's bank</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['victimBankName']" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
@if (request['victimIfsc']) {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Send by</mat-label>
|
||||||
|
<mat-select [(ngModel)]="request['transferChannel']">
|
||||||
|
<mat-option value="NEFT">NEFT</mat-option>
|
||||||
|
<mat-option value="IMPS">IMPS</mat-option>
|
||||||
|
<mat-option value="RTGS">RTGS</mat-option>
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (r.status !== 'IN_POOL') {
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>IO name</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['ioName']" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Police station</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['ioPoliceStation']" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>BNSS 106 notice no.</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['bnssNoticeNo']" maxlength="100" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Notice date</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="request['bnssNoticeDate']" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-checkbox [(ngModel)]="request['indemnityBondReceived']"
|
||||||
|
>Indemnity bond received (Rs 10 stamp paper)</mat-checkbox
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>FIR no. (needed above ₹{{ r.firThreshold }})</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['firNo']" maxlength="100" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>FIR date</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="request['firDate']" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Court order no.</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['courtOrderNo']" maxlength="100" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Court order date</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="request['courtOrderDate']" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Court</mat-label>
|
||||||
|
<input matInput [(ngModel)]="request['courtName']" maxlength="200" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-stroked-button color="primary" (click)="saveRequest()" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Save
|
||||||
|
{{ r.status === 'IN_POOL' ? 'refund account' : 'request' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'REQUESTED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">3. Bank action (as on MRM)</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Action</mat-label>
|
||||||
|
<mat-select [(ngModel)]="action.action" (selectionChange)="action.reason = ''">
|
||||||
|
@for (a of bankActions; track a.value) {
|
||||||
|
<mat-option [value]="a.value">{{ a.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (action.action === 'REFER_BACK_LEA') {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Reason</mat-label>
|
||||||
|
<mat-select [(ngModel)]="action.reason">
|
||||||
|
@for (x of referBackReasons; track x.value) {
|
||||||
|
<mat-option [value]="x.value">{{ x.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
} @else {
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Remarks{{ action.action === 'COMPLIED' ? '' : ' *' }}</mat-label>
|
||||||
|
<input matInput [(ngModel)]="action.reason" maxlength="1000" />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (action.action === 'COMPLIED') {
|
||||||
|
@if (papers.length) {
|
||||||
|
<div class="i4c-note">Still needed before compliance: {{ papers.join(', ') }}.</div>
|
||||||
|
}
|
||||||
|
<div class="sub">
|
||||||
|
₹{{ r.restorable_amount | number: '1.2-2' }} goes to {{ r.victim_name }} ({{
|
||||||
|
r.victim_account_no
|
||||||
|
}}{{ r.victim_ifsc ? ', ' + r.victim_ifsc : ', this bank' }}) once a second officer approves.
|
||||||
|
@if (r.victim_ifsc && !r.poolConfigured) {
|
||||||
|
<b class="err-text"
|
||||||
|
>No restoration pool account is configured, so an outward restoration cannot be
|
||||||
|
approved.</b
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (action.action === 'NOT_COMPLIED' || action.action === 'REFER_BACK_LEA') {
|
||||||
|
<div class="sub">
|
||||||
|
MRM asks for the account's bank statement with this action; attach it on the Documents tab.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
[color]="action.action === 'COMPLIED' ? 'warn' : 'primary'"
|
||||||
|
(click)="takeAction()"
|
||||||
|
[disabled]="busy"
|
||||||
|
>
|
||||||
|
<fa-icon [icon]="action.action === 'COMPLIED' ? 'hand-holding-usd' : 'check'"></fa-icon>
|
||||||
|
{{ action.action === 'COMPLIED' ? 'Raise restoration for approval' : 'Record bank action' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'AWAITING_APPROVAL') {
|
||||||
|
<div class="i4c-note">
|
||||||
|
Restoration request #{{ r.action_request_id }} is waiting for a second officer on the Approvals
|
||||||
|
page. On approval ₹{{ r.restorable_amount | number: '1.2-2' }} leaves {{ r.hold_account_no }} at
|
||||||
|
once.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'IN_POOL') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
₹{{ r.restored_amount | number: '1.2-2' }} is in the restoration pool
|
||||||
|
</div>
|
||||||
|
<div class="sub">
|
||||||
|
It left the suspect account on approval. The outward {{ r.transfer_channel }} to
|
||||||
|
{{ r.victim_account_no }} / {{ r.victim_ifsc }} has not gone through yet{{
|
||||||
|
r.transfer_error ? ': ' + r.transfer_error : '.'
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="retryOutward()" [disabled]="busy">
|
||||||
|
<fa-icon icon="paper-plane"></fa-icon> Send to the victim's bank
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'TRANSFER_PENDING') {
|
||||||
|
<div class="i4c-note">
|
||||||
|
Outward transfer {{ r.fund_transfer_ref }} is {{ r.transfer_status }} (fund-transfer approval /
|
||||||
|
NEFT batch). The I4C Hold Review job marks it restored once it settles.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (r.status === 'RESTORED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">
|
||||||
|
Restored ₹{{ r.restored_amount | number: '1.2-2' }} on
|
||||||
|
{{ r.restored_at | date: 'dd MMM yyyy, HH:mm' }}
|
||||||
|
</div>
|
||||||
|
<div class="sub">
|
||||||
|
On MRM, choose "Compliance of restoration order" and enter amount ₹{{
|
||||||
|
r.restored_amount | number: '1.2-2'
|
||||||
|
}}, transaction id <b>{{ r.utr }}</b> and this date and time.
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="markMrm()" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Entered on MRM (close)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
@if (r.status !== 'CLOSED') {
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Restored money received for our customer</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Amount (₹) *</mat-label>
|
||||||
|
<input matInput type="number" min="0" [(ngModel)]="receipt.amount" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>UTR / transaction id *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="receipt.utr" maxlength="40" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Credited on *</mat-label>
|
||||||
|
<input matInput type="date" [(ngModel)]="receipt.receivedOn" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Credited to account</mat-label>
|
||||||
|
<input matInput [(ngModel)]="receipt.accountNo" maxlength="40" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button mat-flat-button color="primary" (click)="saveReceipt()" [disabled]="busy">
|
||||||
|
<fa-icon icon="check"></fa-icon> Record receipt
|
||||||
|
</button>
|
||||||
|
@if (r.status === 'RECEIVED') {
|
||||||
|
<button mat-stroked-button (click)="markMrm()" [disabled]="busy">
|
||||||
|
Entered on MRM (close)
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<div class="step__title">Or: why no (more) restoration is expected</div>
|
||||||
|
<div class="step__row">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Victim status</mat-label>
|
||||||
|
<mat-select [(ngModel)]="victim.status">
|
||||||
|
@for (v of victimStatuses; track v.value) {
|
||||||
|
<mat-option [value]="v.value">{{ v.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
|
||||||
|
<mat-label>Justification *</mat-label>
|
||||||
|
<input matInput [(ngModel)]="victim.remarks" maxlength="1000" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-actions">
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
(click)="saveVictimStatus()"
|
||||||
|
[disabled]="busy || !victim.remarks.trim()"
|
||||||
|
>
|
||||||
|
<fa-icon icon="check"></fa-icon> Record and close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="i4c-note">
|
||||||
|
Case closed{{ r.victim_status ? ': ' + label(victimStatuses, r.victim_status) : '' }}.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (!isVictim && r.status === 'CLOSED') {
|
||||||
|
<div class="i4c-note">
|
||||||
|
Closed{{
|
||||||
|
r.bank_action === 'NOT_COMPLIED' ? ': order not complied (' + r.bank_action_reason + ')' : ''
|
||||||
|
}}.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'details') {
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="info-circle"></fa-icon> Request & papers</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>MR id</dt>
|
||||||
|
<dd>{{ r.mrm_request_no || '-' }}</dd>
|
||||||
|
<dt>Basis</dt>
|
||||||
|
<dd>{{ label(bases, r.basis) }} {{ r.basis_remarks ? '(' + r.basis_remarks + ')' : '' }}</dd>
|
||||||
|
<dt>Restorable</dt>
|
||||||
|
<dd>{{ r.restorable_amount | number: '1.2-2' }}</dd>
|
||||||
|
<dt>Victim</dt>
|
||||||
|
<dd>
|
||||||
|
{{ r.victim_name }} · {{ r.victim_account_no }} {{ r.victim_ifsc }} {{ r.victim_bank_name }}
|
||||||
|
</dd>
|
||||||
|
<dt>IO</dt>
|
||||||
|
<dd>{{ r.io_name || '-' }} {{ r.io_police_station }}</dd>
|
||||||
|
<dt>BNSS 106</dt>
|
||||||
|
<dd>{{ r.bnss_notice_no || '-' }} {{ r.bnss_notice_date | date: 'dd MMM yyyy' }}</dd>
|
||||||
|
<dt>Indemnity bond</dt>
|
||||||
|
<dd>{{ r.indemnity_bond_received ? 'Received' : 'Not received' }}</dd>
|
||||||
|
<dt>FIR</dt>
|
||||||
|
<dd>{{ r.fir_no || '-' }} {{ r.fir_date | date: 'dd MMM yyyy' }}</dd>
|
||||||
|
<dt>Court order</dt>
|
||||||
|
<dd>
|
||||||
|
{{ r.court_order_no || '-' }} {{ r.court_order_date | date: 'dd MMM yyyy' }} {{ r.court_name }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__head"><fa-icon icon="exchange-alt"></fa-icon> Money</div>
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<dl class="i4c-kv">
|
||||||
|
<dt>Bank action</dt>
|
||||||
|
<dd>{{ r.bank_action }} {{ r.bank_action_reason ? '(' + r.bank_action_reason + ')' : '' }}</dd>
|
||||||
|
<dt>Approval</dt>
|
||||||
|
<dd>
|
||||||
|
@if (r.actionRequest?.length) {
|
||||||
|
#{{ r.actionRequest[0].id }} {{ r.actionRequest[0].status }}
|
||||||
|
{{ r.actionRequest[0].checker_name || '' }}
|
||||||
|
{{ r.actionRequest[0].failure_message || r.actionRequest[0].rejection_reason || '' }}
|
||||||
|
} @else {
|
||||||
|
-
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<dt>Channel</dt>
|
||||||
|
<dd>{{ r.transfer_channel || '-' }} {{ r.transfer_status || '' }}</dd>
|
||||||
|
<dt>Pool transfer</dt>
|
||||||
|
<dd>{{ r.pool_transfer_id || '-' }}</dd>
|
||||||
|
<dt>Outward ref</dt>
|
||||||
|
<dd>{{ r.fund_transfer_ref || '-' }}</dd>
|
||||||
|
<dt>UTR / txn id</dt>
|
||||||
|
<dd>{{ r.utr || r.received_utr || '-' }}</dd>
|
||||||
|
<dt>Restored</dt>
|
||||||
|
<dd>
|
||||||
|
{{ r.restored_amount ? (r.restored_amount | number: '1.2-2') : '-' }}
|
||||||
|
{{ r.restored_at | date: 'dd MMM yyyy, HH:mm' }}
|
||||||
|
</dd>
|
||||||
|
<dt>On MRM</dt>
|
||||||
|
<dd>
|
||||||
|
{{
|
||||||
|
r.mrm_updated
|
||||||
|
? 'Yes, by ' + r.mrm_updated_by + ' ' + (r.mrm_updated_at | date: 'dd MMM yyyy')
|
||||||
|
: 'Not yet'
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (tab === 'documents') {
|
||||||
|
<div class="i4c-card">
|
||||||
|
<div class="i4c-card__body">
|
||||||
|
<mifosx-i4c-documents
|
||||||
|
ownerType="RESTORATION"
|
||||||
|
[ownerId]="r.id"
|
||||||
|
[suggested]="
|
||||||
|
isVictim
|
||||||
|
? ['BANK_STATEMENT', 'COURT_ORDER', 'OTHER']
|
||||||
|
: ['BNSS_106_NOTICE', 'INDEMNITY_BOND', 'FIR', 'COURT_ORDER', 'BANK_STATEMENT', 'PAN']
|
||||||
|
"
|
||||||
|
[documents]="r.documents"
|
||||||
|
[canUpload]="r.status !== 'CLOSED'"
|
||||||
|
(changed)="open(r.id)"
|
||||||
|
></mifosx-i4c-documents>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="close()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
@use '../i4c-cases' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
@include i4c-case-page;
|
||||||
|
|
||||||
|
.highlight td {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-text {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
|
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||||
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
|
||||||
|
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
|
||||||
|
|
||||||
|
export const MRM_BASES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'ATTRIBUTION', label: 'Restoration possible (attribution)' },
|
||||||
|
{ value: 'PRO_RATA', label: 'Restoration possible (pro-rata distribution)' },
|
||||||
|
{ value: 'NOT_POSSIBLE_ALREADY_REFUNDED', label: 'Not possible (already refunded)' },
|
||||||
|
{ value: 'NOT_POSSIBLE_NO_BALANCE', label: 'Not possible (balance not available)' },
|
||||||
|
{ value: 'NOT_POSSIBLE_OTHERS', label: 'Not possible (others)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MRM_STATUSES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'ASSESSED', label: 'Assessed' },
|
||||||
|
{ value: 'REQUESTED', label: 'Victim request received' },
|
||||||
|
{ value: 'AWAITING_APPROVAL', label: 'Awaiting checker' },
|
||||||
|
{ value: 'IN_POOL', label: 'In restoration pool' },
|
||||||
|
{ value: 'TRANSFER_PENDING', label: 'Outward transfer pending' },
|
||||||
|
{ value: 'RESTORED', label: 'Restored' },
|
||||||
|
{ value: 'AWAITED', label: 'Victim bank: awaited' },
|
||||||
|
{ value: 'RECEIVED', label: 'Victim bank: received' },
|
||||||
|
{ value: 'CLOSED', label: 'Closed' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const BANK_ACTIONS = [
|
||||||
|
{ value: 'COMPLIED', label: 'Compliance of restoration order (restore the money)' },
|
||||||
|
{ value: 'NOT_COMPLIED', label: 'Restoration order not complied' },
|
||||||
|
{ value: 'REFER_BACK_LEA', label: 'Refer back to LEA' },
|
||||||
|
{ value: 'REFER_BACK_VICTIM_BANK', label: 'Refer back to victim bank (wrong refund account)' },
|
||||||
|
{ value: 'CONSENSUS', label: 'Consensus request (several holds on the account)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const REFER_BACK_REASONS = [
|
||||||
|
{ value: 'ISSUE_COURT_ORDER', label: 'Issue in court / police order' },
|
||||||
|
{ value: 'ISSUE_FIR', label: 'Issue in FIR details' },
|
||||||
|
{ value: 'ISSUE_ORDER_AND_FIR', label: 'Issue in order and FIR details' },
|
||||||
|
{ value: 'ISSUE_INDEMNITY_BOND', label: 'Issue in indemnity bond' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const VICTIM_STATUSES = [
|
||||||
|
{ value: 'ALREADY_REFUNDED_BANK', label: 'Already refunded through the bank' },
|
||||||
|
{ value: 'ALREADY_REFUNDED_POLICE_NOTICE', label: 'Already refunded (police notice)' },
|
||||||
|
{ value: 'ALREADY_REFUNDED_COURT_ORDER', label: 'Already refunded (court order)' },
|
||||||
|
{ value: 'DECLINED_REFUND', label: 'Victim declined to receive the refund' },
|
||||||
|
{ value: 'DECLINED_TO_PURSUE', label: 'Victim declined to pursue the refund' },
|
||||||
|
{ value: 'RECEIVED_THROUGH_COURT', label: 'Victim received refund through court order' },
|
||||||
|
{ value: 'NOT_REACHABLE', label: 'Victim not reachable' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MRM money restoration. As beneficiary bank: assess, record the victim's request and the IO's papers, take the bank
|
||||||
|
* action, and follow the money to the victim. As victim bank: record restored money received for our customer.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-restorations',
|
||||||
|
templateUrl: './i4c-restorations.component.html',
|
||||||
|
styleUrls: ['./i4c-restorations.component.scss'],
|
||||||
|
imports: [
|
||||||
|
DatePipe,
|
||||||
|
DecimalPipe,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
MatSelectModule,
|
||||||
|
MatProgressBarModule,
|
||||||
|
MatPaginatorModule,
|
||||||
|
FaIconComponent,
|
||||||
|
I4cDateRangeComponent,
|
||||||
|
I4cDocumentsComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cRestorationsComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private snackBar = inject(MatSnackBar);
|
||||||
|
|
||||||
|
readonly bases = MRM_BASES;
|
||||||
|
readonly statuses = MRM_STATUSES;
|
||||||
|
readonly bankActions = BANK_ACTIONS;
|
||||||
|
readonly referBackReasons = REFER_BACK_REASONS;
|
||||||
|
readonly victimStatuses = VICTIM_STATUSES;
|
||||||
|
readonly pageSizes = [
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
50,
|
||||||
|
100
|
||||||
|
];
|
||||||
|
|
||||||
|
role = 'BENEFICIARY_BANK';
|
||||||
|
status = 'OPEN';
|
||||||
|
bankAction = '';
|
||||||
|
search = '';
|
||||||
|
dates: I4cDateRange = { ...ALL_DATES };
|
||||||
|
rows: any[] = [];
|
||||||
|
total = 0;
|
||||||
|
pageIndex = 0;
|
||||||
|
pageSize = 20;
|
||||||
|
loading = false;
|
||||||
|
error = '';
|
||||||
|
victimAck = '';
|
||||||
|
|
||||||
|
selected: any = null;
|
||||||
|
busy = false;
|
||||||
|
tab: 'workflow' | 'details' | 'documents' = 'workflow';
|
||||||
|
assessment = { basis: 'ATTRIBUTION', restorableAmount: null as number | null, remarks: '' };
|
||||||
|
request: Record<string, any> = {};
|
||||||
|
action = { action: 'COMPLIED', reason: '' };
|
||||||
|
receipt = { amount: null as number | null, utr: '', receivedOn: '', accountNo: '' };
|
||||||
|
victim = { status: 'ALREADY_REFUNDED_BANK', remarks: '' };
|
||||||
|
proRata: any = null;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const open = Number(this.route.snapshot.queryParamMap.get('open'));
|
||||||
|
this.load();
|
||||||
|
if (open) {
|
||||||
|
this.open(open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load(fromFirstPage = false): void {
|
||||||
|
if (fromFirstPage) {
|
||||||
|
this.pageIndex = 0;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.i4c
|
||||||
|
.restorations({
|
||||||
|
role: this.role,
|
||||||
|
status: this.status,
|
||||||
|
bankAction: this.bankAction,
|
||||||
|
search: this.search.trim(),
|
||||||
|
fromDate: this.dates.fromDate,
|
||||||
|
toDate: this.dates.toDate,
|
||||||
|
offset: this.pageIndex * this.pageSize,
|
||||||
|
limit: this.pageSize
|
||||||
|
})
|
||||||
|
.subscribe({
|
||||||
|
next: (page) => {
|
||||||
|
this.rows = page.pageItems;
|
||||||
|
this.total = page.totalFilteredRecords;
|
||||||
|
this.loading = false;
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.error = i4cErrorMessage(e);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setRole(r: string): void {
|
||||||
|
this.role = r;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPage(e: PageEvent): void {
|
||||||
|
this.pageIndex = e.pageIndex;
|
||||||
|
this.pageSize = e.pageSize;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDates(range: I4cDateRange): void {
|
||||||
|
this.dates = range;
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilters(): boolean {
|
||||||
|
return !!(this.status !== 'OPEN' || this.bankAction || this.search || this.dates.preset !== 'ALL');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFilters(): void {
|
||||||
|
this.status = 'OPEN';
|
||||||
|
this.bankAction = '';
|
||||||
|
this.search = '';
|
||||||
|
this.dates = { ...ALL_DATES };
|
||||||
|
this.load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
openVictimCase(): void {
|
||||||
|
if (!this.victimAck.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.i4c.openVictimCase(this.victimAck.trim()).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
this.victimAck = '';
|
||||||
|
this.role = 'VICTIM_BANK';
|
||||||
|
this.load(true);
|
||||||
|
this.open(r.resourceId);
|
||||||
|
},
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
open(id: number): void {
|
||||||
|
this.i4c.restoration(id).subscribe({
|
||||||
|
next: (r) => {
|
||||||
|
const same = this.selected?.id === r.id;
|
||||||
|
this.selected = r;
|
||||||
|
if (!same) {
|
||||||
|
this.tab = 'workflow';
|
||||||
|
this.proRata = null;
|
||||||
|
this.assessment = {
|
||||||
|
basis: r.basis || 'ATTRIBUTION',
|
||||||
|
restorableAmount: r.restorable_amount ?? r.amount_held,
|
||||||
|
remarks: r.basis_remarks || ''
|
||||||
|
};
|
||||||
|
this.request = {
|
||||||
|
mrmRequestNo: r.mrm_request_no || '',
|
||||||
|
victimName: r.victim_name || '',
|
||||||
|
victimAccountNo: r.victim_account_no || '',
|
||||||
|
victimIfsc: r.victim_ifsc || '',
|
||||||
|
victimBankName: r.victim_bank_name || '',
|
||||||
|
transferChannel: r.transfer_channel && r.transfer_channel !== 'INTERNAL' ? r.transfer_channel : 'NEFT',
|
||||||
|
ioName: r.io_name || '',
|
||||||
|
ioPoliceStation: r.io_police_station || '',
|
||||||
|
firNo: r.fir_no || '',
|
||||||
|
firDate: r.fir_date || '',
|
||||||
|
courtOrderNo: r.court_order_no || '',
|
||||||
|
courtOrderDate: r.court_order_date || '',
|
||||||
|
courtName: r.court_name || '',
|
||||||
|
bnssNoticeNo: r.bnss_notice_no || '',
|
||||||
|
bnssNoticeDate: r.bnss_notice_date || '',
|
||||||
|
indemnityBondReceived: !!r.indemnity_bond_received
|
||||||
|
};
|
||||||
|
this.action = { action: 'COMPLIED', reason: '' };
|
||||||
|
this.receipt = { amount: null, utr: '', receivedOn: '', accountNo: r.victim_account_no || '' };
|
||||||
|
this.victim = { status: 'ALREADY_REFUNDED_BANK', remarks: '' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
label(list: { value: string; label: string }[], v: string): string {
|
||||||
|
return list.find((x) => x.value === v)?.label || v || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isVictim(): boolean {
|
||||||
|
return this.selected?.role === 'VICTIM_BANK';
|
||||||
|
}
|
||||||
|
|
||||||
|
get possible(): boolean {
|
||||||
|
return this.assessment.basis === 'ATTRIBUTION' || this.assessment.basis === 'PRO_RATA';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Papers MRM needs for compliance, given what is recorded; mirrors the server's check. */
|
||||||
|
get papers(): string[] {
|
||||||
|
const r = this.selected;
|
||||||
|
if (!r) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const has = (t: string) => (r.documents || []).some((d: any) => d.doc_type === t);
|
||||||
|
const out: string[] = [];
|
||||||
|
if (r.court_order_no) {
|
||||||
|
if (!has('COURT_ORDER')) {
|
||||||
|
out.push('court order copy');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!r.bnss_notice_no) {
|
||||||
|
out.push('BNSS 106 notice no.');
|
||||||
|
} else if (!has('BNSS_106_NOTICE')) {
|
||||||
|
out.push('BNSS 106 notice copy');
|
||||||
|
}
|
||||||
|
if (!r.indemnity_bond_received) {
|
||||||
|
out.push('indemnity bond received');
|
||||||
|
} else if (!has('INDEMNITY_BOND')) {
|
||||||
|
out.push('indemnity bond copy');
|
||||||
|
}
|
||||||
|
if (Number(r.restorable_amount) > Number(r.firThreshold)) {
|
||||||
|
if (!r.fir_no) {
|
||||||
|
out.push('FIR no. (above ₹' + r.firThreshold + ')');
|
||||||
|
} else if (!has('FIR')) {
|
||||||
|
out.push('FIR copy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private run(obs: any, done: string): void {
|
||||||
|
this.busy = true;
|
||||||
|
obs.subscribe({
|
||||||
|
next: (r: any) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(done, 'OK', { duration: 5000 });
|
||||||
|
this.open(r?.id ?? r?.resourceId ?? this.selected.id);
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: (e: any) => {
|
||||||
|
this.busy = false;
|
||||||
|
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 8000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAssessment(): void {
|
||||||
|
this.run(
|
||||||
|
this.i4c.assessRestoration(this.selected.hold_id, {
|
||||||
|
basis: this.assessment.basis,
|
||||||
|
restorableAmount: this.possible ? this.assessment.restorableAmount : null,
|
||||||
|
remarks: this.assessment.remarks
|
||||||
|
}),
|
||||||
|
'Assessment saved. Enter the same on MRM (Money Distribution Process).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRequest(): void {
|
||||||
|
this.run(this.i4c.recordRestorationRequest(this.selected.id, this.request), 'Victim request recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
takeAction(): void {
|
||||||
|
this.run(
|
||||||
|
this.i4c.restorationStep(this.selected.id, 'bank-action', this.action),
|
||||||
|
this.action.action === 'COMPLIED'
|
||||||
|
? 'Restoration raised. A second officer approves it on the Approvals page; the money then moves.'
|
||||||
|
: 'Bank action recorded. Enter the same on MRM.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
retryOutward(): void {
|
||||||
|
this.run(this.i4c.restorationStep(this.selected.id, 'retry-outward'), 'Outward transfer sent again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
markMrm(): void {
|
||||||
|
this.run(this.i4c.restorationStep(this.selected.id, 'mrm-updated'), 'Marked as entered on MRM.');
|
||||||
|
}
|
||||||
|
|
||||||
|
saveReceipt(): void {
|
||||||
|
this.run(this.i4c.restorationStep(this.selected.id, 'receipt', this.receipt), 'Receipt recorded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
saveVictimStatus(): void {
|
||||||
|
this.run(
|
||||||
|
this.i4c.restorationStep(this.selected.id, 'victim-status', this.victim),
|
||||||
|
'Victim status recorded; case closed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProRata(): void {
|
||||||
|
this.i4c.proRata(this.selected.hold_account_no).subscribe({
|
||||||
|
next: (p) => {
|
||||||
|
this.proRata = p;
|
||||||
|
const mine = (p.holds || []).find((h: any) => h.id === this.selected.hold_id);
|
||||||
|
if (mine) {
|
||||||
|
this.assessment.basis = 'PRO_RATA';
|
||||||
|
this.assessment.restorableAmount = Number(mine.share);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<!--
|
||||||
|
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="i4c-shell">
|
||||||
|
<!-- Modern Navigation Bar with Live Status and Quick Actions -->
|
||||||
|
<header class="i4c-navbar">
|
||||||
|
<div class="i4c-navbar__brand">
|
||||||
|
<div class="i4c-navbar__icon-wrap">
|
||||||
|
<fa-icon icon="shield-alt"></fa-icon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="i4c-navbar__title">
|
||||||
|
<span>I4C Portal</span>
|
||||||
|
<span class="live-pill">
|
||||||
|
<span class="live-pill__dot"></span>
|
||||||
|
Live MHA Gateway
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="i4c-navbar__sub">National Cyber Crime Reporting & Account Holds System</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="i4c-navbar__actions">
|
||||||
|
<button class="quick-lookup-btn" (click)="openLookup()">
|
||||||
|
<fa-icon icon="search"></fa-icon>
|
||||||
|
<span>Quick Lookup</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Modern Pill-Style Subnavigation Bar -->
|
||||||
|
<nav class="i4c-subnav">
|
||||||
|
<div class="i4c-subnav__links">
|
||||||
|
@for (link of links; track link.path) {
|
||||||
|
<a class="i4c-subnav__link" [routerLink]="['./', link.path]" routerLinkActive="i4c-subnav__link--active">
|
||||||
|
<fa-icon [icon]="link.icon" class="link-ic"></fa-icon>
|
||||||
|
<span>{{ link.label }}</span>
|
||||||
|
@if (link.path === 'approvals' && pendingApprovals > 0) {
|
||||||
|
<span class="pill-badge pill-badge--amber">{{ pendingApprovals }}</span>
|
||||||
|
}
|
||||||
|
@if (link.path === 'holds' && activeHolds > 0) {
|
||||||
|
<span class="pill-badge pill-badge--blue">{{ activeHolds }}</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Outlet for child views -->
|
||||||
|
<main class="i4c-content">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ========================================================
|
||||||
|
MODERN POPUP WINDOW: QUICK LOOKUP MODAL
|
||||||
|
======================================================== -->
|
||||||
|
@if (showLookupModal) {
|
||||||
|
<div class="i4c-modal-backdrop" (click)="closeLookup()">
|
||||||
|
<div class="i4c-modal-window i4c-modal-window--wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="i4c-modal-header">
|
||||||
|
<div class="i4c-modal-header__title">
|
||||||
|
<fa-icon icon="search"></fa-icon>
|
||||||
|
<div>
|
||||||
|
<span>Quick Search & Lookup</span>
|
||||||
|
<div class="i4c-modal-header__sub">Instant search across I4C Complaints and Account Holds Register</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close-btn" (click)="closeLookup()" aria-label="Close">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-body">
|
||||||
|
<div class="lookup-search-box">
|
||||||
|
<div class="search-input-wrap">
|
||||||
|
<fa-icon icon="search" class="search-icon"></fa-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="modern-input"
|
||||||
|
placeholder="Enter acknowledgement no., customer account no., or job ID..."
|
||||||
|
[(ngModel)]="lookupQuery"
|
||||||
|
(keyup.enter)="performLookup()"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
@if (lookupQuery) {
|
||||||
|
<button class="clear-btn" (click)="lookupQuery = ''">
|
||||||
|
<fa-icon icon="times"></fa-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="search-submit-btn"
|
||||||
|
(click)="performLookup()"
|
||||||
|
[disabled]="lookupLoading || !lookupQuery.trim()"
|
||||||
|
>
|
||||||
|
@if (lookupLoading) {
|
||||||
|
<fa-icon icon="sync" animation="spin"></fa-icon>
|
||||||
|
<span>Searching...</span>
|
||||||
|
} @else {
|
||||||
|
<fa-icon icon="search"></fa-icon>
|
||||||
|
<span>Search</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (lookupError) {
|
||||||
|
<div class="i4c-error">{{ lookupError }}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Search Results -->
|
||||||
|
@if (lookupSearched) {
|
||||||
|
<div class="lookup-results">
|
||||||
|
<!-- Complaints Section -->
|
||||||
|
<div class="results-section">
|
||||||
|
<div class="results-section__head">
|
||||||
|
<fa-icon icon="inbox"></fa-icon>
|
||||||
|
<span>Complaints from I4C</span>
|
||||||
|
<span class="count-badge">{{ lookupResults.complaints.length }}</span>
|
||||||
|
</div>
|
||||||
|
@if (lookupResults.complaints.length > 0) {
|
||||||
|
<div class="results-list">
|
||||||
|
@for (c of lookupResults.complaints; track c.id) {
|
||||||
|
<div class="result-item" (click)="goToComplaint(c.id)">
|
||||||
|
<div class="result-item__main">
|
||||||
|
<div class="result-item__title">
|
||||||
|
<span class="ack-code">{{ c.acknowledgement_no }}</span>
|
||||||
|
<span class="status-pill status-pill--{{ c.status?.toLowerCase() }}">{{ c.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item__sub">
|
||||||
|
<span
|
||||||
|
>Account: <b>{{ c.payer_account_number }}</b></span
|
||||||
|
>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Job: {{ c.job_id }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Sub-cat: {{ c.sub_category }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-item__side">
|
||||||
|
<span class="held-amt">Held: ₹{{ c.amount_held | number: '1.2-2' }}</span>
|
||||||
|
<span class="open-link">Open Complaint <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="no-res">No complaints matched "{{ lookupQuery }}".</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Holds Section -->
|
||||||
|
<div class="results-section">
|
||||||
|
<div class="results-section__head">
|
||||||
|
<fa-icon icon="lock"></fa-icon>
|
||||||
|
<span>Holds Register</span>
|
||||||
|
<span class="count-badge">{{ lookupResults.holds.length }}</span>
|
||||||
|
</div>
|
||||||
|
@if (lookupResults.holds.length > 0) {
|
||||||
|
<div class="results-list">
|
||||||
|
@for (h of lookupResults.holds; track h.id) {
|
||||||
|
<div class="result-item" (click)="goToHold(h.account_no)">
|
||||||
|
<div class="result-item__main">
|
||||||
|
<div class="result-item__title">
|
||||||
|
<span class="ack-code">Account: {{ h.account_no }}</span>
|
||||||
|
<span class="status-pill status-pill--{{ h.status?.toLowerCase() }}">{{ h.status }}</span>
|
||||||
|
@if (h.debit_frozen) {
|
||||||
|
<span class="freeze-tag">DEBIT FROZEN</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="result-item__sub">
|
||||||
|
<span
|
||||||
|
>Customer: <b>{{ h.client_name || 'N/A' }}</b></span
|
||||||
|
>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Ack: {{ h.acknowledgement_no }}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Layer: {{ h.layer }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-item__side">
|
||||||
|
<span class="held-amt">Held: ₹{{ h.amount_held | number: '1.2-2' }}</span>
|
||||||
|
<span class="open-link">View in Holds <fa-icon icon="arrow-right"></fa-icon></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="no-res">No holds matched "{{ lookupQuery }}".</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="lookup-hint">
|
||||||
|
<fa-icon icon="lightbulb" class="hint-icon"></fa-icon>
|
||||||
|
<span
|
||||||
|
>Type a 14-digit acknowledgement number, customer bank account number, or job ID to quickly locate
|
||||||
|
complaints or hold records.</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="i4c-modal-footer">
|
||||||
|
<button class="footer-btn-close" (click)="closeLookup()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
/**
|
||||||
|
* 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/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use '../i4c' as *;
|
||||||
|
|
||||||
|
@include i4c-skin;
|
||||||
|
|
||||||
|
.i4c-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Top Header / Navbar */
|
||||||
|
.i4c-navbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 10px 25px -5px rgb(15 23 42 / 20%);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon-wrap {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #2563eb, #3b82f6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 10px rgb(37 99 235 / 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: rgb(16 185 129 / 15%);
|
||||||
|
color: #34d399;
|
||||||
|
border: 1px solid rgb(16 185 129 / 30%);
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #10b981;
|
||||||
|
box-shadow: 0 0 6px #10b981;
|
||||||
|
animation: pulse-dot 1.8s infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-lookup-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: rgb(255 255 255 / 10%);
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid rgb(255 255 255 / 18%);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgb(255 255 255 / 20%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 10px rgb(0 0 0 / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd-shortcut {
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgb(255 255 255 / 15%);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Pill Navigation Bar */
|
||||||
|
.i4c-subnav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgb(15 23 42 / 3%);
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
&__links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.link-ic {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background: #f1f5f9;
|
||||||
|
|
||||||
|
.link-ic {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff !important;
|
||||||
|
box-shadow: 0 3px 10px rgb(37 99 235 / 28%);
|
||||||
|
|
||||||
|
.link-ic {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-badge {
|
||||||
|
background: rgb(255 255 255 / 25%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-badge {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
|
||||||
|
&--amber {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quick Lookup Popup Modal styling */
|
||||||
|
.lookup-search-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 32px 0 34px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #0f172a;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgb(37 99 235 / 15%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-submit-btn {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #1d4ed8;
|
||||||
|
box-shadow: 0 3px 10px rgb(37 99 235 / 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11.5px;
|
||||||
|
|
||||||
|
.hint-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lookup-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-section {
|
||||||
|
&__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
fa-icon {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.ack-code {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.held-amt {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-link {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2563eb;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-res {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
|
||||||
|
&--processed,
|
||||||
|
&--active {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--retry,
|
||||||
|
&--failed {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--processing,
|
||||||
|
&--received {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.freeze-tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn-close {
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
border: none;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
transform: scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { I4cService, i4cErrorMessage } from '../i4c.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live I4C (NCRP-CFCFRMS) integration: complaints I4C pushed to the bank, the holds placed for them, the
|
||||||
|
* approvals to release them, and every response sent back. Backed by /v1/i4c/admin on the CBS, never by the
|
||||||
|
* CFCFRMS demo mock.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'mifosx-i4c-shell',
|
||||||
|
templateUrl: './i4c-shell.component.html',
|
||||||
|
styleUrls: ['./i4c-shell.component.scss'],
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
RouterLinkActive,
|
||||||
|
RouterOutlet,
|
||||||
|
MatButtonModule,
|
||||||
|
FaIconComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class I4cShellComponent implements OnInit {
|
||||||
|
private i4c = inject(I4cService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
readonly links = [
|
||||||
|
{ path: 'dashboard', label: 'Dashboard', icon: 'chart-line' },
|
||||||
|
{ path: 'requests', label: 'Complaints from I4C', icon: 'inbox' },
|
||||||
|
{ path: 'holds', label: 'Holds Register', icon: 'lock' },
|
||||||
|
{ path: 'approvals', label: 'Approvals', icon: 'user-check' },
|
||||||
|
{ path: 'grievances', label: 'GRM Grievances', icon: 'balance-scale' },
|
||||||
|
{ path: 'restorations', label: 'MRM Restorations', icon: 'hand-holding-usd' },
|
||||||
|
{ path: 'mrm-report', label: 'MRM Balance Report', icon: 'file-csv' },
|
||||||
|
{ path: 'outbox', label: 'Responses & API Log', icon: 'paper-plane' },
|
||||||
|
{ path: 'bank-master', label: 'Bank Code Master', icon: 'university' },
|
||||||
|
{ path: 'intimation-settings', label: 'Intimation Settings', icon: 'cog' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Quick lookup popup modal state
|
||||||
|
showLookupModal = false;
|
||||||
|
lookupQuery = '';
|
||||||
|
lookupLoading = false;
|
||||||
|
lookupResults: { complaints: any[]; holds: any[] } = { complaints: [], holds: [] };
|
||||||
|
lookupSearched = false;
|
||||||
|
lookupError = '';
|
||||||
|
|
||||||
|
// Summary counts for pill badges
|
||||||
|
pendingApprovals = 0;
|
||||||
|
activeHolds = 0;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.refreshSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSummary(): void {
|
||||||
|
this.i4c.dashboard().subscribe({
|
||||||
|
next: (d) => {
|
||||||
|
this.pendingApprovals = d?.pendingApprovals || 0;
|
||||||
|
this.activeHolds = d?.activeHolds || 0;
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openLookup(): void {
|
||||||
|
this.showLookupModal = true;
|
||||||
|
this.lookupQuery = '';
|
||||||
|
this.lookupResults = { complaints: [], holds: [] };
|
||||||
|
this.lookupSearched = false;
|
||||||
|
this.lookupError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeLookup(): void {
|
||||||
|
this.showLookupModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
performLookup(): void {
|
||||||
|
const q = this.lookupQuery.trim();
|
||||||
|
if (!q) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lookupLoading = true;
|
||||||
|
this.lookupSearched = true;
|
||||||
|
this.lookupError = '';
|
||||||
|
this.lookupResults = { complaints: [], holds: [] };
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
const checkDone = () => {
|
||||||
|
completed++;
|
||||||
|
if (completed >= 2) {
|
||||||
|
this.lookupLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.i4c.requests({ search: q, limit: 10 }).subscribe({
|
||||||
|
next: (rows) => {
|
||||||
|
this.lookupResults.complaints = rows?.pageItems || [];
|
||||||
|
checkDone();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.lookupError = i4cErrorMessage(e);
|
||||||
|
checkDone();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.i4c.holds({ search: q, limit: 10 }).subscribe({
|
||||||
|
next: (rows) => {
|
||||||
|
this.lookupResults.holds = rows?.pageItems || [];
|
||||||
|
checkDone();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
checkDone();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
goToComplaint(id: number): void {
|
||||||
|
this.closeLookup();
|
||||||
|
this.router.navigate([
|
||||||
|
'/cfcfrms/i4c/requests',
|
||||||
|
id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
goToHold(accountNo: string): void {
|
||||||
|
this.closeLookup();
|
||||||
|
this.router.navigate(['/cfcfrms/i4c/holds'], { queryParams: { view: '' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/** A hold the bank placed for an I4C complaint (m_i4c_hold). */
|
||||||
|
export interface I4cHold {
|
||||||
|
id: number;
|
||||||
|
request_id: number;
|
||||||
|
incident_id: number;
|
||||||
|
acknowledgement_no: string;
|
||||||
|
account_type: 'SAVINGS' | 'CURRENT';
|
||||||
|
account_id: number;
|
||||||
|
account_no: string;
|
||||||
|
client_id?: number;
|
||||||
|
client_name?: string;
|
||||||
|
office_name?: string;
|
||||||
|
layer: number;
|
||||||
|
disputed_amount: number;
|
||||||
|
amount_held: number;
|
||||||
|
shortfall: number;
|
||||||
|
status: 'ACTIVE' | 'RELEASED' | 'NOT_HOLDABLE';
|
||||||
|
debit_frozen: boolean;
|
||||||
|
held_at: string;
|
||||||
|
review_due_on: string;
|
||||||
|
intimation_due_by?: string;
|
||||||
|
customer_intimated_at?: string;
|
||||||
|
intimation_mode?: string;
|
||||||
|
intimation_reference?: string;
|
||||||
|
intimated_by?: string;
|
||||||
|
released_at?: string;
|
||||||
|
release_reason_type?: string;
|
||||||
|
release_reference?: string;
|
||||||
|
pending_actions?: number;
|
||||||
|
transactions?: any[];
|
||||||
|
actions?: any[];
|
||||||
|
/** Every notice sent or recorded for this hold, newest first. */
|
||||||
|
intimationLog?: IntimationLogEntry[];
|
||||||
|
/** Channels switched on for automatic intimation, in the order they are tried. */
|
||||||
|
automaticChannels?: string[];
|
||||||
|
grievances?: any[];
|
||||||
|
restorations?: any[];
|
||||||
|
documents?: I4cDocument[];
|
||||||
|
released_amount?: number;
|
||||||
|
restored_amount?: number;
|
||||||
|
auto_intimation_attempts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntimationLogEntry {
|
||||||
|
id: number;
|
||||||
|
channel: string;
|
||||||
|
mode: 'AUTO' | 'MANUAL' | 'RESEND';
|
||||||
|
status: string;
|
||||||
|
destination_masked?: string;
|
||||||
|
provider?: string;
|
||||||
|
provider_reference?: string;
|
||||||
|
failure_reason?: string;
|
||||||
|
actor?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An automatic customer-intimation channel (m_i4c_intimation_channel). */
|
||||||
|
export interface IntimationChannel {
|
||||||
|
channel: 'EMAIL' | 'SMS' | 'WHATSAPP';
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
subject?: string;
|
||||||
|
body: string;
|
||||||
|
updated_by?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntimationSettings {
|
||||||
|
channels: IntimationChannel[];
|
||||||
|
placeholders: string[];
|
||||||
|
manualChannels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntimationResult {
|
||||||
|
outcome: 'SENT' | 'NO_CHANNEL_ENABLED' | 'NOT_DELIVERED' | 'ALREADY_INTIMATED' | 'NOTHING_HELD';
|
||||||
|
channel?: string;
|
||||||
|
destination?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type I4cDocumentOwner = 'HOLD' | 'GRIEVANCE' | 'RESTORATION';
|
||||||
|
|
||||||
|
/** A document attached to a hold, grievance or restoration (content downloaded separately). */
|
||||||
|
export interface I4cDocument {
|
||||||
|
id: number;
|
||||||
|
doc_type: string;
|
||||||
|
file_name: string;
|
||||||
|
content_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
sha256: string;
|
||||||
|
remarks?: string;
|
||||||
|
uploaded_by: string;
|
||||||
|
uploaded_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DOCUMENT_TYPES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'IO_ORDER', label: 'IO order' },
|
||||||
|
{ value: 'GRM_ORDER', label: 'GRM order (DGRO / SGRO)' },
|
||||||
|
{ value: 'COURT_ORDER', label: 'Court order' },
|
||||||
|
{ value: 'BNSS_106_NOTICE', label: 'Notice u/s 106 BNSS' },
|
||||||
|
{ value: 'INDEMNITY_BOND', label: 'Indemnity bond' },
|
||||||
|
{ value: 'FIR', label: 'FIR copy' },
|
||||||
|
{ value: 'KYC', label: 'KYC document' },
|
||||||
|
{ value: 'APPLICATION', label: "Customer's application" },
|
||||||
|
{ value: 'PHOTO', label: 'Photograph' },
|
||||||
|
{ value: 'BANK_STATEMENT', label: 'Bank statement' },
|
||||||
|
{ value: 'EDD_REPORT', label: 'EDD report' },
|
||||||
|
{ value: 'PAN', label: 'PAN card' },
|
||||||
|
{ value: 'OTHER', label: 'Other' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Save a downloaded file (CSV, document) under the given name. */
|
||||||
|
export function saveBlob(blob: Blob, fileName: string): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One page of an officer list, as every /i4c/admin list endpoint returns it. */
|
||||||
|
export interface I4cPage<T> {
|
||||||
|
totalFilteredRecords: number;
|
||||||
|
pageItems: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query-string filters of a list; empty values are left out of the request. */
|
||||||
|
export type I4cListParams = Record<string, string | number | boolean | null | undefined>;
|
||||||
|
|
||||||
|
export interface HoldActionRequest {
|
||||||
|
action: 'RELEASE' | 'FREEZE' | 'UNFREEZE' | 'UNFREEZE_AND_RELEASE';
|
||||||
|
/** RELEASE only: how much to release; empty releases all. */
|
||||||
|
amount?: number | null;
|
||||||
|
reasonType: string;
|
||||||
|
orderReference?: string;
|
||||||
|
orderDate?: string;
|
||||||
|
authority?: string;
|
||||||
|
remarks?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOLD_REASON_TYPES: { value: string; label: string }[] = [
|
||||||
|
{ value: 'IO_ORDER', label: 'Investigating Officer order' },
|
||||||
|
{ value: 'GRM_DECISION', label: 'GRM decision (IO / DGRO / SGRO)' },
|
||||||
|
{ value: 'MRM_RESTORATION', label: 'MRM money restoration' },
|
||||||
|
{ value: 'COURT_ORDER', label: 'Court order' },
|
||||||
|
{ value: 'LEA_DIRECTION', label: 'LEA direction' },
|
||||||
|
{ value: 'REVIEW_90_DAYS', label: '90-day review (no direction received)' },
|
||||||
|
{ value: 'WRONG_ACCOUNT', label: 'Wrong account reported' },
|
||||||
|
{ value: 'OTHER', label: 'Other' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Officer API of the I4C (NCRP-CFCFRMS) integration: /v1/i4c/admin/* on the CBS backend. */
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class I4cService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
private base = '/i4c/admin';
|
||||||
|
private cases = '/i4c/cases';
|
||||||
|
|
||||||
|
dashboard(): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.base}/dashboard`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: status, search, subCategory, layer, response, fromDate, toDate, offset, limit. */
|
||||||
|
requests(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.base}/requests`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
request(id: number): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.base}/requests/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
reprocess(id: number): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/requests/${id}/reprocess`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: view, search, accountType, intimation, frozen, layer, fromDate, toDate, offset, limit. */
|
||||||
|
holds(query: I4cListParams = {}): Observable<I4cPage<I4cHold>> {
|
||||||
|
return this.http.get<I4cPage<I4cHold>>(`${this.base}/holds`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
hold(id: number): Observable<I4cHold> {
|
||||||
|
return this.http.get<I4cHold>(`${this.base}/holds/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestHoldAction(holdId: number, body: HoldActionRequest): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/holds/${holdId}/actions`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordIntimation(holdId: number, mode: string, reference?: string): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/holds/${holdId}/intimation`, { mode, reference });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: status, action, reasonType, search, fromDate, toDate, offset, limit. */
|
||||||
|
holdActions(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.base}/hold-actions`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
approveHoldAction(id: number): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/hold-actions/${id}/approve`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectHoldAction(id: number, reason: string): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/hold-actions/${id}/reject`, { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: status, type (ACK / RESPONSE), search, fromDate, toDate, offset, limit. */
|
||||||
|
outbox(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.base}/outbox`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
retryOutbox(id: number): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.base}/outbox/${id}/retry`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: direction, failures, httpStatus, responseCode, search, fromDate, toDate, offset, limit. */
|
||||||
|
apiLog(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.base}/api-log`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
apiLogEntry(id: number): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.base}/api-log/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send the hold notice now on the switched-on automatic channels. */
|
||||||
|
sendIntimation(holdId: number): Observable<IntimationResult> {
|
||||||
|
return this.http.post<IntimationResult>(`${this.base}/holds/${holdId}/intimation/send`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
intimationChannels(): Observable<IntimationSettings> {
|
||||||
|
return this.http.get<IntimationSettings>(`${this.base}/intimation-channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIntimationChannel(
|
||||||
|
channel: string,
|
||||||
|
body: { enabled?: boolean; priority?: number; subject?: string; body?: string }
|
||||||
|
): Observable<IntimationChannel[]> {
|
||||||
|
return this.http.put<IntimationChannel[]>(`${this.base}/intimation-channels/${channel}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
testIntimationChannel(channel: string, to: string): Observable<IntimationResult> {
|
||||||
|
return this.http.post<IntimationResult>(`${this.base}/intimation-channels/${channel}/test`, { to });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters: search, type, ifsc (KNOWN / UNKNOWN), offset, limit. */
|
||||||
|
bankMaster(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.base}/bank-master`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GRM / MRM (/v1/i4c/cases) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
grievances(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.cases}/grievances`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
grievance(id: number): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.cases}/grievances/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createGrievance(
|
||||||
|
holdId: number,
|
||||||
|
body: { customerRequest: string; customerReason?: string }
|
||||||
|
): Observable<{ resourceId: number }> {
|
||||||
|
return this.http.post<{ resourceId: number }>(`${this.cases}/holds/${holdId}/grievances`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** step: edd, submit, nodal, events, investigation, decision, escalate, withdraw */
|
||||||
|
grievanceStep(id: number, step: string, body: Record<string, any>): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.cases}/grievances/${id}/${step}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
raiseGrievanceAction(id: number): Observable<{ resourceId: number; actionRequestId: number | null }> {
|
||||||
|
return this.http.post<{ resourceId: number; actionRequestId: number | null }>(
|
||||||
|
`${this.cases}/grievances/${id}/action`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
restorations(query: I4cListParams = {}): Observable<I4cPage<any>> {
|
||||||
|
return this.http.get<I4cPage<any>>(`${this.cases}/restorations`, { params: this.params(query) });
|
||||||
|
}
|
||||||
|
|
||||||
|
restoration(id: number): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.cases}/restorations/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assessRestoration(
|
||||||
|
holdId: number,
|
||||||
|
body: { basis: string; restorableAmount?: number | null; remarks?: string }
|
||||||
|
): Observable<{ resourceId: number }> {
|
||||||
|
return this.http.post<{ resourceId: number }>(`${this.cases}/holds/${holdId}/restorations`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRestorationRequest(id: number, body: Record<string, any>): Observable<any> {
|
||||||
|
return this.http.put<any>(`${this.cases}/restorations/${id}/request`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** step: bank-action, retry-outward, mrm-updated, receipt, victim-status */
|
||||||
|
restorationStep(id: number, step: string, body: Record<string, any> = {}): Observable<any> {
|
||||||
|
return this.http.post<any>(`${this.cases}/restorations/${id}/${step}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
openVictimCase(acknowledgementNo: string): Observable<{ resourceId: number }> {
|
||||||
|
return this.http.post<{ resourceId: number }>(`${this.cases}/victim-cases`, { acknowledgementNo });
|
||||||
|
}
|
||||||
|
|
||||||
|
proRata(accountNo: string, amount?: number | null): Observable<any> {
|
||||||
|
return this.http.get<any>(`${this.cases}/pro-rata`, { params: this.params({ accountNo, amount }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
mrmBalanceReport(): Observable<any[]> {
|
||||||
|
return this.http.get<any[]>(`${this.cases}/mrm-balance-report`);
|
||||||
|
}
|
||||||
|
|
||||||
|
mrmBalanceReportCsv(): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.cases}/mrm-balance-report.csv`, { responseType: 'blob' });
|
||||||
|
}
|
||||||
|
|
||||||
|
statementCsv(holdId: number, fromDate?: string, toDate?: string): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.cases}/holds/${holdId}/statement.csv`, {
|
||||||
|
params: this.params({ fromDate, toDate }),
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
documents(ownerType: I4cDocumentOwner, ownerId: number): Observable<I4cDocument[]> {
|
||||||
|
return this.http.get<I4cDocument[]>(`${this.cases}/documents`, { params: this.params({ ownerType, ownerId }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadDocument(body: {
|
||||||
|
ownerType: I4cDocumentOwner;
|
||||||
|
ownerId: number;
|
||||||
|
docType: string;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
content: string;
|
||||||
|
remarks?: string;
|
||||||
|
}): Observable<{ resourceId: number }> {
|
||||||
|
return this.http.post<{ resourceId: number }>(`${this.cases}/documents`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadDocument(id: number): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.cases}/documents/${id}/content`, { responseType: 'blob' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private params(values: Record<string, any>): HttpParams {
|
||||||
|
let params = new HttpParams();
|
||||||
|
Object.entries(values).forEach(
|
||||||
|
([
|
||||||
|
key,
|
||||||
|
value
|
||||||
|
]) => {
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
params = params.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backend errors come as Fineract's { defaultUserMessage, errors[] }; this picks the sentence a user should read. */
|
||||||
|
export function i4cErrorMessage(error: any): string {
|
||||||
|
const body = error?.error;
|
||||||
|
return (
|
||||||
|
body?.errors?.[0]?.defaultUserMessage ||
|
||||||
|
body?.defaultUserMessage ||
|
||||||
|
body?.developerMessage ||
|
||||||
|
error?.message ||
|
||||||
|
'Request failed'
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,27 +102,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Money moving between branches: the approver must see it before approving -->
|
|
||||||
@if (isInterBranch) {
|
|
||||||
<div class="dri-interbranch">
|
|
||||||
<mat-icon>alt_route</mat-icon>
|
|
||||||
<div>
|
|
||||||
<div class="dri-interbranch__title">Inter-branch transfer</div>
|
|
||||||
<div class="dri-interbranch__text">
|
|
||||||
@if (branchNames(drEntries) && branchNames(crEntries)) {
|
|
||||||
Money moves from <strong>{{ branchNames(drEntries) }}</strong> to
|
|
||||||
<strong>{{ branchNames(crEntries) }}</strong
|
|
||||||
>.
|
|
||||||
} @else {
|
|
||||||
These accounts are held at different branches: <strong>{{ branchNames(creditEntries) }}</strong
|
|
||||||
>.
|
|
||||||
}
|
|
||||||
Each account is posted in its own branch's books. Check the accounts before approving.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Bank Transfer summary card (replaces GL card) -->
|
<!-- Bank Transfer summary card (replaces GL card) -->
|
||||||
@if (isBankTransferMode) {
|
@if (isBankTransferMode) {
|
||||||
<div class="dri-card">
|
<div class="dri-card">
|
||||||
@@ -175,7 +154,6 @@
|
|||||||
<th>Account No</th>
|
<th>Account No</th>
|
||||||
<th>Account Name</th>
|
<th>Account Name</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Branch</th>
|
|
||||||
<th class="num">{{ isCollectionMode ? 'Amount (DR)' : 'Amount (CR)' }}</th>
|
<th class="num">{{ isCollectionMode ? 'Amount (DR)' : 'Amount (CR)' }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -188,16 +166,11 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
|
||||||
entry.officeName || '—'
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
<tr class="dri-cr-total-row">
|
<tr class="dri-cr-total-row">
|
||||||
<td colspan="5" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
|
<td colspan="4" class="dri-cr-total-label">{{ isCollectionMode ? 'Total Debit' : 'Total Credit' }}</td>
|
||||||
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -219,7 +192,6 @@
|
|||||||
<th>Account No</th>
|
<th>Account No</th>
|
||||||
<th>Account Name</th>
|
<th>Account Name</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Branch</th>
|
|
||||||
<th class="num">Amount (DR)</th>
|
<th class="num">Amount (DR)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -232,16 +204,11 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
|
||||||
entry.officeName || '—'
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
<tr class="dri-cr-total-row">
|
<tr class="dri-cr-total-row">
|
||||||
<td colspan="5" class="dri-cr-total-label">Total Debit</td>
|
<td colspan="4" class="dri-cr-total-label">Total Debit</td>
|
||||||
<td class="num dri-cr-total-amount">₹{{ totalDebitAmount() | number: '1.2-2' }}</td>
|
<td class="num dri-cr-total-amount">₹{{ totalDebitAmount() | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -261,7 +228,6 @@
|
|||||||
<th>Account No</th>
|
<th>Account No</th>
|
||||||
<th>Account Name</th>
|
<th>Account Name</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Branch</th>
|
|
||||||
<th class="num">Amount (CR)</th>
|
<th class="num">Amount (CR)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -274,16 +240,11 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<span class="dri-branch-badge" [class.dri-branch-badge--cross]="isInterBranch">{{
|
|
||||||
entry.officeName || '—'
|
|
||||||
}}</span>
|
|
||||||
</td>
|
|
||||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
<tr class="dri-cr-total-row">
|
<tr class="dri-cr-total-row">
|
||||||
<td colspan="5" class="dri-cr-total-label">Total Credit</td>
|
<td colspan="4" class="dri-cr-total-label">Total Credit</td>
|
||||||
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
<td class="num dri-cr-total-amount">₹{{ totalCreditAmount() | number: '1.2-2' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -534,54 +534,3 @@ $shadow-md: 0 4px 16px rgb(0 0 0 / 9%);
|
|||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A leg's branch. Amber on every leg when the request moves money between branches, neutral otherwise. */
|
|
||||||
.dri-branch-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: #f1f5f9;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #475569;
|
|
||||||
|
|
||||||
&--cross {
|
|
||||||
background: #fffbeb;
|
|
||||||
border-color: #fcd34d;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Shown above the summary when the accounts are at different branches. */
|
|
||||||
.dri-interbranch {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px solid #fcd34d;
|
|
||||||
border-left: 4px solid #f59e0b;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fffbeb;
|
|
||||||
color: #78350f;
|
|
||||||
|
|
||||||
mat-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__text {
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ interface CreditEntry {
|
|||||||
accountType: string;
|
accountType: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
dbCr?: string;
|
dbCr?: string;
|
||||||
/** The branch the account is held at; absent for a GL leg or when the server could not resolve it. */
|
|
||||||
officeId?: number | null;
|
|
||||||
officeName?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -170,19 +167,6 @@ export class DisbursementReviewComponent implements OnInit {
|
|||||||
return this.creditEntries.filter((e) => e.dbCr !== 'Dr');
|
return this.creditEntries.filter((e) => e.dbCr !== 'Dr');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* True when the accounts in this request are held at more than one branch, so money moves between branches. The
|
|
||||||
* approver has to see that before approving: it is booked in each branch's own books.
|
|
||||||
*/
|
|
||||||
get isInterBranch(): boolean {
|
|
||||||
return new Set(this.creditEntries.filter((e) => e.officeId != null).map((e) => e.officeId)).size > 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Distinct branch names among the given legs, in order, e.g. for "Head Office" or "Head Office, bhopal". */
|
|
||||||
branchNames(entries: CreditEntry[]): string {
|
|
||||||
return [...new Set(entries.map((e) => e.officeName).filter((name): name is string => !!name))].join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
totalCreditAmount(): number {
|
totalCreditAmount(): number {
|
||||||
const entries = this.isCollectionMode ? this.drEntries : this.crEntries;
|
const entries = this.isCollectionMode ? this.drEntries : this.crEntries;
|
||||||
return entries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
|
return entries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
|
||||||
|
|||||||
+1
-11
@@ -930,10 +930,6 @@
|
|||||||
createClientForm.get('mobileNo')?.hasError('pattern') && createClientForm.get('mobileNo')?.touched
|
createClientForm.get('mobileNo')?.hasError('pattern') && createClientForm.get('mobileNo')?.touched
|
||||||
) {
|
) {
|
||||||
<mat-error>Enter valid 10-digit number (starts with 6-9)</mat-error>
|
<mat-error>Enter valid 10-digit number (starts with 6-9)</mat-error>
|
||||||
} @else if (createClientForm.get('mobileNo')?.hasError('contactTaken')) {
|
|
||||||
<mat-error
|
|
||||||
>This mobile number already belongs to another customer. Please enter a different number.</mat-error
|
|
||||||
>
|
|
||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
@@ -944,11 +940,7 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
type="button"
|
type="button"
|
||||||
class="verify-btn"
|
class="verify-btn"
|
||||||
[disabled]="
|
[disabled]="readOnly || createClientForm.get('mobileNo')?.value?.length !== 10"
|
||||||
readOnly ||
|
|
||||||
createClientForm.get('mobileNo')?.value?.length !== 10 ||
|
|
||||||
createClientForm.get('mobileNo')?.hasError('contactTaken')
|
|
||||||
"
|
|
||||||
(click)="verifyWithDIP()"
|
(click)="verifyWithDIP()"
|
||||||
>
|
>
|
||||||
<fa-icon icon="shield-alt" class="m-r-10"></fa-icon>
|
<fa-icon icon="shield-alt" class="m-r-10"></fa-icon>
|
||||||
@@ -1016,8 +1008,6 @@
|
|||||||
createClientForm.get('emailAddress')?.hasError('required') && createClientForm.get('emailAddress')?.touched
|
createClientForm.get('emailAddress')?.hasError('required') && createClientForm.get('emailAddress')?.touched
|
||||||
) {
|
) {
|
||||||
<mat-error>Email is <strong>required</strong></mat-error>
|
<mat-error>Email is <strong>required</strong></mat-error>
|
||||||
} @else if (createClientForm.get('emailAddress')?.hasError('contactTaken')) {
|
|
||||||
<mat-error>This email already belongs to another customer. Please enter a different email.</mat-error>
|
|
||||||
} @else if (createClientForm.get('emailAddress')?.invalid && createClientForm.get('emailAddress')?.touched) {
|
} @else if (createClientForm.get('emailAddress')?.invalid && createClientForm.get('emailAddress')?.touched) {
|
||||||
<mat-error>Enter a valid email (e.g. name@example.com)</mat-error>
|
<mat-error>Enter a valid email (e.g. name@example.com)</mat-error>
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-47
@@ -27,16 +27,15 @@ import {
|
|||||||
UntypedFormControl,
|
UntypedFormControl,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
ValidatorFn,
|
ValidatorFn,
|
||||||
AsyncValidatorFn,
|
|
||||||
AbstractControl,
|
AbstractControl,
|
||||||
ValidationErrors
|
ValidationErrors
|
||||||
} from '@angular/forms';
|
} from '@angular/forms';
|
||||||
import { Subject, Subscription, merge, of, timer } from 'rxjs';
|
import { Subject, Subscription, merge } from 'rxjs';
|
||||||
import { catchError, filter, map, switchMap, takeUntil } from 'rxjs/operators';
|
import { filter, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
import { ClientsService } from 'app/clients/clients.service';
|
import { ClientsService } from 'app/clients/clients.service';
|
||||||
import { Dates } from 'app/core/utils/dates';
|
import { Dates } from 'app/core/utils/dates';
|
||||||
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
import { LegalFormId } from 'app/clients/models/legal-form.enum';
|
||||||
import { toPersonNameCase, toProperNameCase } from 'app/clients/utils/name-case.util';
|
import { toProperNameCase } from 'app/clients/utils/name-case.util';
|
||||||
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
import { ExternalNationalIdService } from 'app/clients/services/external-national-id.service';
|
||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
@@ -937,29 +936,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A mobile number or email that already belongs to another customer. This is a different person using a contact
|
|
||||||
* that is taken (a PAN or Aadhaar match is what means the same customer), so the answer is to enter another one.
|
|
||||||
* Runs only once the format validators pass, and a new keystroke cancels the previous call, which is what
|
|
||||||
* debounces it. The client being edited is excluded so its own number is not flagged.
|
|
||||||
*/
|
|
||||||
private contactTakenValidator(field: 'mobileNo' | 'emailAddress'): AsyncValidatorFn {
|
|
||||||
return (control: AbstractControl) => {
|
|
||||||
const value = (control.value ?? '').toString().trim();
|
|
||||||
if (!value) {
|
|
||||||
return of(null);
|
|
||||||
}
|
|
||||||
return timer(400).pipe(
|
|
||||||
switchMap(() =>
|
|
||||||
this.clientService.lookupClientIdentity({ [field]: value, excludeClientId: this.clientData?.id ?? null })
|
|
||||||
),
|
|
||||||
map((result) => (result?.contactConflicts?.[field] ? { contactTaken: true } : null)),
|
|
||||||
// A failed lookup must not block the form: the server still refuses a duplicate when the client is saved.
|
|
||||||
catchError(() => of(null))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the client form.
|
* Creates the client form.
|
||||||
*/
|
*/
|
||||||
@@ -993,8 +969,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.required,
|
Validators.required,
|
||||||
Validators.pattern(/^[6-9]\d{9}$/),
|
Validators.pattern(/^[6-9]\d{9}$/),
|
||||||
Validators.maxLength(10)
|
Validators.maxLength(10)
|
||||||
],
|
]
|
||||||
this.contactTakenValidator('mobileNo')
|
|
||||||
],
|
],
|
||||||
alternateMobileNo: [
|
alternateMobileNo: [
|
||||||
'',
|
'',
|
||||||
@@ -1011,8 +986,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
Validators.email,
|
Validators.email,
|
||||||
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
|
Validators.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
|
||||||
Validators.maxLength(100)
|
Validators.maxLength(100)
|
||||||
],
|
]
|
||||||
this.contactTakenValidator('emailAddress')
|
|
||||||
],
|
],
|
||||||
dateOfBirth: [
|
dateOfBirth: [
|
||||||
'',
|
'',
|
||||||
@@ -1482,10 +1456,7 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
const control = this.createClientForm.get(controlName);
|
const control = this.createClientForm.get(controlName);
|
||||||
if (control && control.value) {
|
if (control && control.value) {
|
||||||
const originalValue = control.value;
|
const originalValue = control.value;
|
||||||
// An entity's full name keeps its typed spelling (matched against the PAN name); a person's
|
const capitalized = toProperNameCase(originalValue);
|
||||||
// name parts are title-cased, the same as the Full Name line shows them.
|
|
||||||
const capitalized =
|
|
||||||
controlName === 'fullname' ? toProperNameCase(originalValue) : toPersonNameCase(originalValue);
|
|
||||||
if (originalValue !== capitalized) {
|
if (originalValue !== capitalized) {
|
||||||
control.setValue(capitalized);
|
control.setValue(capitalized);
|
||||||
}
|
}
|
||||||
@@ -1954,11 +1925,12 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
|
|
||||||
/** Returns capitalized full name preview from first + middle + last */
|
/** Returns capitalized full name preview from first + middle + last */
|
||||||
get fullNamePreview(): string {
|
get fullNamePreview(): string {
|
||||||
|
const capitalize = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : '');
|
||||||
const salutationId = this.createClientForm?.get('salutationId')?.value;
|
const salutationId = this.createClientForm?.get('salutationId')?.value;
|
||||||
const salutation = this.salutationOptions?.find((s: any) => s.id === salutationId)?.name || '';
|
const salutation = this.salutationOptions?.find((s: any) => s.id === salutationId)?.name || '';
|
||||||
const first = toPersonNameCase(this.createClientForm?.get('firstname')?.value);
|
const first = capitalize(this.createClientForm?.get('firstname')?.value || '');
|
||||||
const middle = toPersonNameCase(this.createClientForm?.get('middlename')?.value);
|
const middle = capitalize(this.createClientForm?.get('middlename')?.value || '');
|
||||||
const last = toPersonNameCase(this.createClientForm?.get('lastname')?.value);
|
const last = capitalize(this.createClientForm?.get('lastname')?.value || '');
|
||||||
const full = [
|
const full = [
|
||||||
first,
|
first,
|
||||||
middle,
|
middle,
|
||||||
@@ -2388,15 +2360,6 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
|
|||||||
if (generalDetails.fullname) {
|
if (generalDetails.fullname) {
|
||||||
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
|
generalDetails.fullname = toProperNameCase(generalDetails.fullname);
|
||||||
}
|
}
|
||||||
for (const part of [
|
|
||||||
'firstname',
|
|
||||||
'middlename',
|
|
||||||
'lastname'
|
|
||||||
]) {
|
|
||||||
if (generalDetails[part]) {
|
|
||||||
generalDetails[part] = toPersonNameCase(generalDetails[part]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generalDetails.submittedOnDate instanceof Date) {
|
if (generalDetails.submittedOnDate instanceof Date) {
|
||||||
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
|
generalDetails.submittedOnDate = this.dateUtils.formatDate(generalDetails.submittedOnDate, dateFormat);
|
||||||
|
|||||||
-211
@@ -1,211 +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/.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<section class="identity-check" [class.identity-check--blocking]="status === 'matched' || status === 'conflict'">
|
|
||||||
<header class="identity-check__header">
|
|
||||||
<mat-icon class="identity-check__icon">fingerprint</mat-icon>
|
|
||||||
<div>
|
|
||||||
<h3 class="identity-check__title">Existing customer check</h3>
|
|
||||||
<p class="identity-check__hint">
|
|
||||||
@if (isEntity) {
|
|
||||||
Enter the company's GSTIN or PAN. If either is already on file in any branch, it is the same customer and a
|
|
||||||
new one is not created.
|
|
||||||
} @else {
|
|
||||||
Enter PAN or Aadhaar. If either is already on file in any branch, it is the same customer and a new one is not
|
|
||||||
created. Nothing found? What you enter here is filled into the KYC step for you.
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@if (form) {
|
|
||||||
<form [formGroup]="form" class="identity-check__fields">
|
|
||||||
@if (isEntity) {
|
|
||||||
<mat-form-field appearance="outline" class="identity-check__field">
|
|
||||||
<mat-label>GSTIN</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
formControlName="gstin"
|
|
||||||
class="identity-check__upper"
|
|
||||||
maxlength="15"
|
|
||||||
mifosxRestrictInput="gstin"
|
|
||||||
[errorStateMatcher]="errorMatcher"
|
|
||||||
placeholder="e.g. 22AAAAA0000A1Z5"
|
|
||||||
(input)="capitalise('gstin', $event)"
|
|
||||||
/>
|
|
||||||
@if (form.get('gstin')?.hasError('pattern')) {
|
|
||||||
<mat-error>Invalid GSTIN. It has 15 characters, like 22AAAAA0000A1Z5</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
<mat-form-field appearance="outline" class="identity-check__field">
|
|
||||||
<mat-label>{{ isEntity ? 'Company PAN' : 'PAN' }}</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
formControlName="pan"
|
|
||||||
class="identity-check__upper"
|
|
||||||
maxlength="10"
|
|
||||||
mifosxRestrictInput="pan"
|
|
||||||
[errorStateMatcher]="errorMatcher"
|
|
||||||
placeholder="e.g. ABCDE1234F"
|
|
||||||
(input)="capitalise('pan', $event)"
|
|
||||||
/>
|
|
||||||
@if (form.get('pan')?.hasError('pattern')) {
|
|
||||||
<mat-error>Invalid PAN. Use 5 letters, 4 digits, 1 letter, like ABCDE1234F</mat-error>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
|
|
||||||
@if (!isEntity) {
|
|
||||||
<mat-form-field appearance="outline" class="identity-check__field">
|
|
||||||
<mat-label>Aadhaar</mat-label>
|
|
||||||
<input
|
|
||||||
matInput
|
|
||||||
formControlName="aadhaar"
|
|
||||||
maxlength="12"
|
|
||||||
inputmode="numeric"
|
|
||||||
mifosxRestrictInput="digits"
|
|
||||||
[errorStateMatcher]="errorMatcher"
|
|
||||||
placeholder="12 digits"
|
|
||||||
/>
|
|
||||||
@if (form.get('aadhaar')?.hasError('pattern')) {
|
|
||||||
<mat-error>Invalid Aadhaar. It must be exactly 12 digits</mat-error>
|
|
||||||
} @else {
|
|
||||||
<mat-hint>Only a fingerprint of this number is sent, never the number</mat-hint>
|
|
||||||
}
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
|
|
||||||
@switch (status) {
|
|
||||||
@case ('checking') {
|
|
||||||
<div class="identity-check__status identity-check__status--info">
|
|
||||||
<mat-progress-spinner mode="indeterminate" diameter="18"></mat-progress-spinner>
|
|
||||||
Checking all branches…
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('not-found') {
|
|
||||||
<div class="identity-check__status identity-check__status--ok">
|
|
||||||
<mat-icon>check_circle</mat-icon>
|
|
||||||
<span>
|
|
||||||
No existing customer found. You can continue; these details will be filled into the KYC step so you do not
|
|
||||||
type them again.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('error') {
|
|
||||||
<div class="identity-check__status identity-check__status--warn">
|
|
||||||
<mat-icon>warning</mat-icon>
|
|
||||||
<span>
|
|
||||||
Could not check right now. You can continue; the PAN, Aadhaar and GSTIN are checked again when the KYC is
|
|
||||||
saved.
|
|
||||||
</span>
|
|
||||||
<button mat-button type="button" (click)="recheck()">Check again</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@case ('conflict') {
|
|
||||||
<div class="identity-check__status identity-check__status--error">
|
|
||||||
<mat-icon>report</mat-icon>
|
|
||||||
<span>
|
|
||||||
These details belong to <strong>different customers</strong>. Check what was typed. If they are correct, ask a
|
|
||||||
manager to review before continuing.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@for (m of matches; track m.clientId) {
|
|
||||||
<div class="identity-check__card">
|
|
||||||
<strong>{{ m.displayName }}</strong>
|
|
||||||
<span class="identity-check__meta">CIF {{ m.cifNumber }} · {{ m.officeName }}</span>
|
|
||||||
<span class="identity-check__chips">
|
|
||||||
@for (by of m.matchedBy; track by) {
|
|
||||||
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@case ('matched') {
|
|
||||||
@if (match; as m) {
|
|
||||||
<div class="identity-check__status identity-check__status--error">
|
|
||||||
<mat-icon>person_search</mat-icon>
|
|
||||||
<span> This is an <strong>existing customer</strong>. A new client is not created for the same person. </span>
|
|
||||||
</div>
|
|
||||||
<div class="identity-check__card">
|
|
||||||
<div class="identity-check__card-head">
|
|
||||||
<strong>{{ m.displayName }}</strong>
|
|
||||||
<span class="identity-check__chips">
|
|
||||||
@for (by of m.matchedBy; track by) {
|
|
||||||
<span class="identity-check__chip">{{ matchedByLabel(by) }} matches</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<dl class="identity-check__facts">
|
|
||||||
<div>
|
|
||||||
<dt>CIF</dt>
|
|
||||||
<dd>{{ m.cifNumber }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Home branch</dt>
|
|
||||||
<dd>{{ m.officeName }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Type</dt>
|
|
||||||
<dd>{{ legalFormLabel(m) }}</dd>
|
|
||||||
</div>
|
|
||||||
@if (m.panMasked) {
|
|
||||||
<div>
|
|
||||||
<dt>PAN</dt>
|
|
||||||
<dd>{{ m.panMasked }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (m.aadhaarMasked) {
|
|
||||||
<div>
|
|
||||||
<dt>Aadhaar</dt>
|
|
||||||
<dd>{{ m.aadhaarMasked }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (m.gstinMasked) {
|
|
||||||
<div>
|
|
||||||
<dt>GSTIN</dt>
|
|
||||||
<dd>{{ m.gstinMasked }}</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
@if (m.visibleToCaller) {
|
|
||||||
<button
|
|
||||||
mat-flat-button
|
|
||||||
color="primary"
|
|
||||||
type="button"
|
|
||||||
class="identity-check__action"
|
|
||||||
[routerLink]="['/clients', m.clientId, 'general']"
|
|
||||||
>
|
|
||||||
Open this customer
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<p class="identity-check__note">
|
|
||||||
This customer is registered at <strong>{{ m.officeName }}</strong
|
|
||||||
>. They stay a single customer (one CIF). Open their page, then choose which account to open for them at
|
|
||||||
this branch. They are listed under this branch once an account is opened here.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
mat-flat-button
|
|
||||||
color="primary"
|
|
||||||
type="button"
|
|
||||||
class="identity-check__action"
|
|
||||||
[disabled]="linking"
|
|
||||||
(click)="openAccountHere(m)"
|
|
||||||
>
|
|
||||||
{{ linking ? 'Please wait…' : 'Open account for this customer' }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
-157
@@ -1,157 +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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity-check {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
background: #f9fafb;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
&--blocking {
|
|
||||||
border-color: #fecaca;
|
|
||||||
background: #fffafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__header {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
flex: none;
|
|
||||||
color: var(--md-sys-color-primary, #1074b9);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__hint {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PAN and GSTIN read in capitals even before the value is rewritten.
|
|
||||||
&__upper {
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__fields {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__field {
|
|
||||||
flex: 1 1 220px;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__status {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
&--info {
|
|
||||||
background: #eff6ff;
|
|
||||||
color: #1e40af;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--ok {
|
|
||||||
background: #ecfdf5;
|
|
||||||
color: #047857;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--warn {
|
|
||||||
background: #fffbeb;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--error {
|
|
||||||
background: #fef2f2;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__card {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #fecaca;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__card-head {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 12px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__meta {
|
|
||||||
margin-left: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__chips {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__chip {
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #991b1b;
|
|
||||||
background: #fee2e2;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__facts {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
||||||
gap: 8px 16px;
|
|
||||||
margin: 10px 0 0;
|
|
||||||
|
|
||||||
dt {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__action {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__note {
|
|
||||||
margin: 12px 0 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-308
@@ -1,308 +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,
|
|
||||||
EventEmitter,
|
|
||||||
Input,
|
|
||||||
OnChanges,
|
|
||||||
OnDestroy,
|
|
||||||
OnInit,
|
|
||||||
Output,
|
|
||||||
SimpleChanges,
|
|
||||||
inject
|
|
||||||
} from '@angular/core';
|
|
||||||
import {
|
|
||||||
AbstractControl,
|
|
||||||
FormGroupDirective,
|
|
||||||
NgForm,
|
|
||||||
UntypedFormBuilder,
|
|
||||||
UntypedFormGroup,
|
|
||||||
Validators
|
|
||||||
} from '@angular/forms';
|
|
||||||
import { ErrorStateMatcher } from '@angular/material/core';
|
|
||||||
import { Router } from '@angular/router';
|
|
||||||
import { MatIcon } from '@angular/material/icon';
|
|
||||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
|
||||||
import { Observable, Subject, merge, of } from 'rxjs';
|
|
||||||
import { catchError, debounceTime, map, switchMap, takeUntil, tap } from 'rxjs/operators';
|
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|
||||||
import { AuthenticationService } from 'app/core/authentication/authentication.service';
|
|
||||||
import { RestrictInputDirective } from '../../../directives/restrict-input.directive';
|
|
||||||
import { ClientsService } from '../../clients.service';
|
|
||||||
import { ClientIdentityLookupResult, ClientIdentityMatch } from '../../models/client-identity.model';
|
|
||||||
import { aadhaarHash } from '../../utils/aadhaar-hash.util';
|
|
||||||
|
|
||||||
export type ClientIdentityStatus = 'idle' | 'checking' | 'not-found' | 'matched' | 'conflict' | 'error';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the parent needs from the identity check: whether creation must stop, and the identifiers the officer entered
|
|
||||||
* so the KYC step can be filled in without typing them again.
|
|
||||||
*/
|
|
||||||
export interface ClientIdentityState {
|
|
||||||
status: ClientIdentityStatus;
|
|
||||||
/** Individual: the person's PAN. Entity: the company's PAN. */
|
|
||||||
pan: string | null;
|
|
||||||
/** The 12 digits, held in memory only to pre-fill the KYC step. Never sent: only its hash is. */
|
|
||||||
aadhaar: string | null;
|
|
||||||
gstin: string | null;
|
|
||||||
/** True when the customer already exists (or the identifiers disagree), so a new client must not be created. */
|
|
||||||
blocking: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a field's error as soon as something has been typed in it, not only after the field is left: a PAN that is
|
|
||||||
* wrong should say so while it is being typed, and the form should not look fine in the meantime.
|
|
||||||
*/
|
|
||||||
class ShowErrorWhenDirty implements ErrorStateMatcher {
|
|
||||||
isErrorState(control: AbstractControl | null, _form: FormGroupDirective | NgForm | null): boolean {
|
|
||||||
return !!control && control.invalid && (control.dirty || control.touched);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAN_PATTERN = /^[A-Za-z]{5}\d{4}[A-Za-z]$/;
|
|
||||||
const AADHAAR_PATTERN = /^\d{12}$/;
|
|
||||||
const GSTIN_PATTERN = /^\d{2}[A-Za-z]{5}\d{4}[A-Za-z][1-9A-Za-z]Z[0-9A-Za-z]$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Is this person already a customer of the bank?" — asked at the top of the General step, before anything is saved.
|
|
||||||
*
|
|
||||||
* Any ONE of PAN / Aadhaar (individual) or PAN / GSTIN (entity) matching an existing customer, in any branch, means
|
|
||||||
* the same customer, so a second client must not be created. When nothing matches, what was entered is carried to the
|
|
||||||
* KYC step so it does not have to be typed again.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'mifosx-client-identity-check',
|
|
||||||
templateUrl: './client-identity-check.component.html',
|
|
||||||
styleUrls: ['./client-identity-check.component.scss'],
|
|
||||||
imports: [
|
|
||||||
...STANDALONE_SHARED_IMPORTS,
|
|
||||||
MatIcon,
|
|
||||||
MatProgressSpinner,
|
|
||||||
RestrictInputDirective
|
|
||||||
]
|
|
||||||
})
|
|
||||||
export class ClientIdentityCheckComponent implements OnInit, OnChanges, OnDestroy {
|
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
|
||||||
private clientsService = inject(ClientsService);
|
|
||||||
private router = inject(Router);
|
|
||||||
private authenticationService = inject(AuthenticationService);
|
|
||||||
|
|
||||||
/** 1 = individual, 2 = entity (non-individual). */
|
|
||||||
@Input() legalFormType = 1;
|
|
||||||
@Output() identityChange = new EventEmitter<ClientIdentityState>();
|
|
||||||
|
|
||||||
form: UntypedFormGroup;
|
|
||||||
readonly errorMatcher = new ShowErrorWhenDirty();
|
|
||||||
status: ClientIdentityStatus = 'idle';
|
|
||||||
matches: ClientIdentityMatch[] = [];
|
|
||||||
/** True while the customer is being introduced to this branch. */
|
|
||||||
linking = false;
|
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
|
||||||
private recheck$ = new Subject<void>();
|
|
||||||
|
|
||||||
get isEntity(): boolean {
|
|
||||||
return this.legalFormType === 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The first (and normally only) existing customer found. */
|
|
||||||
get match(): ClientIdentityMatch | null {
|
|
||||||
return this.matches[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True while a field holds something that is not a valid PAN / Aadhaar / GSTIN. Such a value is never looked up or
|
|
||||||
* carried to the KYC step, so the parent must not let the officer carry on past it as if the check had passed.
|
|
||||||
*/
|
|
||||||
get hasInvalidInput(): boolean {
|
|
||||||
return !!this.form?.invalid;
|
|
||||||
}
|
|
||||||
|
|
||||||
markAllAsTouched(): void {
|
|
||||||
this.form?.markAllAsTouched();
|
|
||||||
}
|
|
||||||
|
|
||||||
get state(): ClientIdentityState {
|
|
||||||
const v = this.normalized();
|
|
||||||
return {
|
|
||||||
status: this.status,
|
|
||||||
pan: v.pan,
|
|
||||||
aadhaar: v.aadhaar,
|
|
||||||
gstin: v.gstin,
|
|
||||||
blocking: this.status === 'matched' || this.status === 'conflict'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.form = this.formBuilder.group({
|
|
||||||
pan: [
|
|
||||||
'',
|
|
||||||
Validators.pattern(PAN_PATTERN)
|
|
||||||
],
|
|
||||||
aadhaar: [
|
|
||||||
'',
|
|
||||||
Validators.pattern(AADHAAR_PATTERN)
|
|
||||||
],
|
|
||||||
gstin: [
|
|
||||||
'',
|
|
||||||
Validators.pattern(GSTIN_PATTERN)
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// A pause in typing (or a manual re-check) runs one lookup; typing again cancels the one in flight.
|
|
||||||
merge(this.form.valueChanges, this.recheck$)
|
|
||||||
.pipe(
|
|
||||||
debounceTime(400),
|
|
||||||
tap(() => this.startChecking()),
|
|
||||||
switchMap(() => this.lookup()),
|
|
||||||
takeUntil(this.destroy$)
|
|
||||||
)
|
|
||||||
.subscribe((result) => this.applyResult(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
|
||||||
// Switching individual <-> entity swaps which identifiers apply; what was typed for the other kind is stale.
|
|
||||||
if (changes['legalFormType'] && !changes['legalFormType'].firstChange) {
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.destroy$.next();
|
|
||||||
this.destroy$.complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clears the fields and the result, e.g. when a new client creation session starts. */
|
|
||||||
reset(): void {
|
|
||||||
this.form?.reset({ pan: '', aadhaar: '', gstin: '' }, { emitEvent: false });
|
|
||||||
this.status = 'idle';
|
|
||||||
this.matches = [];
|
|
||||||
this.identityChange.emit(this.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
recheck(): void {
|
|
||||||
this.recheck$.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PAN and GSTIN are always written in capitals, so what is typed is capitalised as it is typed. The caret is put
|
|
||||||
* back where it was, otherwise editing the middle of a PAN would throw it to the end. The shared restrict-input
|
|
||||||
* directive only capitalises when the field is left, which let lower-case show while typing.
|
|
||||||
*/
|
|
||||||
capitalise(controlName: 'pan' | 'gstin', event: Event): void {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
const upper = input.value.toUpperCase();
|
|
||||||
if (upper !== input.value) {
|
|
||||||
const { selectionStart, selectionEnd } = input;
|
|
||||||
input.value = upper;
|
|
||||||
input.setSelectionRange(selectionStart, selectionEnd);
|
|
||||||
}
|
|
||||||
this.form.get(controlName)?.setValue(upper, { emitEvent: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Identifiers that are complete and well formed; anything half-typed is ignored rather than looked up. */
|
|
||||||
private normalized(): { pan: string | null; aadhaar: string | null; gstin: string | null } {
|
|
||||||
const raw = this.form?.getRawValue() ?? { pan: '', aadhaar: '', gstin: '' };
|
|
||||||
const pan = String(raw.pan ?? '')
|
|
||||||
.replace(/\s+/g, '')
|
|
||||||
.toUpperCase();
|
|
||||||
const aadhaar = String(raw.aadhaar ?? '').replace(/[\s-]/g, '');
|
|
||||||
const gstin = String(raw.gstin ?? '')
|
|
||||||
.replace(/\s+/g, '')
|
|
||||||
.toUpperCase();
|
|
||||||
return {
|
|
||||||
pan: PAN_PATTERN.test(pan) ? pan : null,
|
|
||||||
aadhaar: !this.isEntity && AADHAAR_PATTERN.test(aadhaar) ? aadhaar : null,
|
|
||||||
gstin: this.isEntity && GSTIN_PATTERN.test(gstin) ? gstin : null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private startChecking(): void {
|
|
||||||
const v = this.normalized();
|
|
||||||
if (!v.pan && !v.aadhaar && !v.gstin) {
|
|
||||||
this.status = 'idle';
|
|
||||||
this.matches = [];
|
|
||||||
this.identityChange.emit(this.state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.status = 'checking';
|
|
||||||
this.matches = [];
|
|
||||||
this.identityChange.emit(this.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
private lookup(): Observable<ClientIdentityLookupResult | 'error' | null> {
|
|
||||||
const v = this.normalized();
|
|
||||||
if (!v.pan && !v.aadhaar && !v.gstin) {
|
|
||||||
return of(null);
|
|
||||||
}
|
|
||||||
return this.clientsService
|
|
||||||
.lookupClientIdentity({ panNumber: v.pan, aadhaarHash: aadhaarHash(v.aadhaar), gstinNumber: v.gstin })
|
|
||||||
.pipe(
|
|
||||||
map((result): ClientIdentityLookupResult | 'error' => result),
|
|
||||||
catchError(() => of('error' as const))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyResult(result: ClientIdentityLookupResult | 'error' | null): void {
|
|
||||||
if (result === null) {
|
|
||||||
// Nothing complete to look up; startChecking() already reset to idle.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result === 'error') {
|
|
||||||
// Not blocking: the server checks again when the KYC is saved, so a failed lookup must not stop onboarding.
|
|
||||||
this.status = 'error';
|
|
||||||
this.matches = [];
|
|
||||||
} else if (result.conflict) {
|
|
||||||
this.status = 'conflict';
|
|
||||||
this.matches = result.matches;
|
|
||||||
} else if (result.matched) {
|
|
||||||
this.status = 'matched';
|
|
||||||
this.matches = result.matches;
|
|
||||||
} else {
|
|
||||||
this.status = 'not-found';
|
|
||||||
this.matches = [];
|
|
||||||
}
|
|
||||||
this.identityChange.emit(this.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts opening an account at THIS branch for a customer registered at another one. The customer stays at the home
|
|
||||||
* branch (one CIF). This branch is introduced to them, which only lets it open their page; they are listed under
|
|
||||||
* this branch once an account is actually opened here. An account opened for a customer of another branch is held
|
|
||||||
* at the branch that opens it.
|
|
||||||
*/
|
|
||||||
openAccountHere(match: ClientIdentityMatch): void {
|
|
||||||
const officeId = this.authenticationService.getCredentials()?.officeId;
|
|
||||||
if (this.linking || officeId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.linking = true;
|
|
||||||
this.clientsService.linkClientToMyBranch(match.clientId).subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.linking = false;
|
|
||||||
this.router.navigate([
|
|
||||||
'/clients',
|
|
||||||
match.clientId,
|
|
||||||
'general'
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
// The failure is shown by the global error handler.
|
|
||||||
error: () => (this.linking = false)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Legal form of the existing customer, for the card. */
|
|
||||||
legalFormLabel(match: ClientIdentityMatch): string {
|
|
||||||
return match.legalFormEnum === 2 ? 'Non-Individual' : 'Individual';
|
|
||||||
}
|
|
||||||
|
|
||||||
matchedByLabel(by: string): string {
|
|
||||||
return by === 'AADHAAR' ? 'Aadhaar' : by;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,8 +43,6 @@ import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
|
|||||||
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
|
||||||
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
|
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
|
||||||
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||||
import { aadhaarHash } from '../../utils/aadhaar-hash.util';
|
|
||||||
import { ClientIdentityState } from '../client-identity-check/client-identity-check.component';
|
|
||||||
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
import { DocumentPreviewDialogComponent } from '../../shared/document-preview-dialog/document-preview-dialog.component';
|
||||||
import {
|
import {
|
||||||
PanDeclarationDialogComponent,
|
PanDeclarationDialogComponent,
|
||||||
@@ -110,32 +108,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
@Input() verificationDateOfBirth: Date | null = null;
|
@Input() verificationDateOfBirth: Date | null = null;
|
||||||
/** Read-only mode — disables form editing until user clicks edit icon */
|
/** Read-only mode — disables form editing until user clicks edit icon */
|
||||||
@Input() readOnly = false;
|
@Input() readOnly = false;
|
||||||
|
|
||||||
/**
|
|
||||||
* The identifiers the officer already typed into the existing-customer check on the General step. They are filled
|
|
||||||
* in here so they are not typed twice, but only into fields that are still empty: whatever was entered on this
|
|
||||||
* step wins. Ignored while the check says the customer already exists, since no client is created then.
|
|
||||||
*/
|
|
||||||
@Input() set identityPrefill(identity: ClientIdentityState | null) {
|
|
||||||
if (!identity || identity.blocking) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const isEmpty = (name: string) => !String(this.kycForm?.get(name)?.value ?? '').trim();
|
|
||||||
const patch: Record<string, unknown> = {};
|
|
||||||
if (this.isNonIndividual) {
|
|
||||||
if (identity.gstin && isEmpty('gstinNumber')) patch['gstinNumber'] = identity.gstin;
|
|
||||||
if (identity.pan && isEmpty('enterprisePan')) patch['enterprisePan'] = identity.pan;
|
|
||||||
} else {
|
|
||||||
if (identity.pan && isEmpty('panNumber')) {
|
|
||||||
patch['panNumber'] = identity.pan;
|
|
||||||
patch['hasPan'] = true;
|
|
||||||
}
|
|
||||||
if (identity.aadhaar && isEmpty('aadhaarNumber')) patch['aadhaarNumber'] = identity.aadhaar;
|
|
||||||
}
|
|
||||||
if (Object.keys(patch).length) {
|
|
||||||
this.kycForm.patchValue(patch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@Input() calculatedRisk: {
|
@Input() calculatedRisk: {
|
||||||
category: number;
|
category: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
@@ -1784,8 +1756,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
kycType: this.kycTypeValue,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.currentRiskCategory,
|
riskCategory: this.currentRiskCategory,
|
||||||
panNumber: pan,
|
panNumber: pan,
|
||||||
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000',
|
aadhaarNumberMasked: aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : 'XXXXXXXX0000'
|
||||||
...this.aadhaarHashField(aadhaarRaw)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
|
// A proprietorship reaches this method (it verifies the proprietor's individual PAN) but is
|
||||||
@@ -2387,8 +2358,7 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
kycType: this.kycTypeValue,
|
kycType: this.kycTypeValue,
|
||||||
riskCategory: this.currentRiskCategory,
|
riskCategory: this.currentRiskCategory,
|
||||||
aadhaarNumberMasked: aadhaarMasked,
|
aadhaarNumberMasked: aadhaarMasked
|
||||||
...this.aadhaarHashField(aadhaar)
|
|
||||||
};
|
};
|
||||||
const panVal = this.kycForm.value.panNumber;
|
const panVal = this.kycForm.value.panNumber;
|
||||||
if (panVal) {
|
if (panVal) {
|
||||||
@@ -2709,15 +2679,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* `{ aadhaarHash }` for a full 12-digit Aadhaar, `{}` otherwise. The number itself is never sent: KYC calls are
|
|
||||||
* recorded server-side, so only its SHA-256 goes.
|
|
||||||
*/
|
|
||||||
private aadhaarHashField(aadhaar: string | null | undefined): { aadhaarHash?: string } {
|
|
||||||
const hash = aadhaarHash(aadhaar);
|
|
||||||
return hash ? { aadhaarHash: hash } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
get kycData(): any {
|
get kycData(): any {
|
||||||
const formVal = this.kycForm.getRawValue();
|
const formVal = this.kycForm.getRawValue();
|
||||||
const aadhaarRaw = formVal.aadhaarNumber || '';
|
const aadhaarRaw = formVal.aadhaarNumber || '';
|
||||||
@@ -2746,9 +2707,6 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
// For non-individual: Company PAN is used as panNumber for verification
|
// For non-individual: Company PAN is used as panNumber for verification
|
||||||
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
panNumber: this.isNonIndividual ? enterprisePanNorm : v.hasPan ? panNorm : null,
|
||||||
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
aadhaarNumberMasked: this.isNonIndividual ? 'XXXXXXXX0000' : aadhaarMasked,
|
||||||
// The fingerprint of the full number, so the server can tell two Aadhaar numbers apart (the masked form keeps
|
|
||||||
// only the last four digits). Absent when the field holds a masked value reloaded from the server.
|
|
||||||
...(this.isNonIndividual ? {} : this.aadhaarHashField(aadhaarRaw)),
|
|
||||||
// Only ever sent when this screen actually holds a value. The seeding status is derived by
|
// Only ever sent when this screen actually holds a value. The seeding status is derived by
|
||||||
// the provider during PAN verification and written straight to the record, so sending an
|
// the provider during PAN verification and written straight to the record, so sending an
|
||||||
// empty one back would blank a field the server filled in — which reads as an officer
|
// empty one back would blank a field the server filled in — which reads as an officer
|
||||||
|
|||||||
@@ -356,15 +356,6 @@
|
|||||||
<button mat-menu-item *mifosxHasPermission="'CREATE_CURRENTACCOUNT'" [routerLink]="['current-accounts', 'create']">
|
<button mat-menu-item *mifosxHasPermission="'CREATE_CURRENTACCOUNT'" [routerLink]="['current-accounts', 'create']">
|
||||||
{{ 'labels.buttons.New Current Account' | translate }}
|
{{ 'labels.buttons.New Current Account' | translate }}
|
||||||
</button>
|
</button>
|
||||||
<!-- A Cash Credit account is a current account on a product that allows an overdraft against a sanctioned limit. -->
|
|
||||||
<button
|
|
||||||
mat-menu-item
|
|
||||||
*mifosxHasPermission="'CREATE_CURRENTACCOUNT'"
|
|
||||||
[routerLink]="['current-accounts', 'create']"
|
|
||||||
[queryParams]="{ cc: 1 }"
|
|
||||||
>
|
|
||||||
New CC Account
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
|
|
||||||
<mat-menu #Actions="matMenu">
|
<mat-menu #Actions="matMenu">
|
||||||
|
|||||||
@@ -90,21 +90,6 @@
|
|||||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Last Active">
|
<ng-container matColumnDef="Last Active">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||||
@@ -218,21 +203,6 @@
|
|||||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Closed Date">
|
<ng-container matColumnDef="Closed Date">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
||||||
@@ -292,21 +262,6 @@
|
|||||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Last Active">
|
<ng-container matColumnDef="Last Active">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||||
@@ -497,21 +452,6 @@
|
|||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fixed Deposit Product' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fixed Deposit Product' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.productName }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.productName }}</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Last Active">
|
<ng-container matColumnDef="Last Active">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Last Active' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.lastActiveTransactionDate | dateFormat }}</td>
|
||||||
@@ -611,21 +551,6 @@
|
|||||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Closed Date">
|
<ng-container matColumnDef="Closed Date">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Closed Date' | translate }}</th>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.timeline.closedOnDate | dateFormat }}</td>
|
||||||
@@ -686,27 +611,10 @@
|
|||||||
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
<mifosx-long-text textValue="{{ element.productName }}" chars="35"></mifosx-long-text>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="Branch">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Branch' | translate }}</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
|
||||||
@if (element.officeName) {
|
|
||||||
<span [class]="branchChipClass(element)" [title]="branchChipTitle(element)">
|
|
||||||
<i class="fa fa-map-marker"></i>{{ element.officeName }}
|
|
||||||
@if (isHomeBranch(element)) {
|
|
||||||
<span class="branch-chip__home">Home</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
—
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
<ng-container matColumnDef="Status">
|
<ng-container matColumnDef="Status">
|
||||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||||
<td mat-cell *matCellDef="let element">
|
<td mat-cell *matCellDef="let element">
|
||||||
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{
|
<span class="rd-status" [class.rd-status--ended]="element.timeline?.closedOnDate">{{ rdStatusLabel(element) }}</span>
|
||||||
rdStatusLabel(element)
|
|
||||||
}}</span>
|
|
||||||
@if (element.timeline?.closedOnDate) {
|
@if (element.timeline?.closedOnDate) {
|
||||||
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
|
<div class="rd-status__date">on {{ element.timeline.closedOnDate | dateFormat }}</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,66 +440,3 @@ table[mat-table] {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Branch an account is held at, as a pill coloured by where the signed-in user works: green for their own branch, red for
|
|
||||||
* any other. When the customer banks at several other branches the reds run from light to dark, one shade per branch.
|
|
||||||
*/
|
|
||||||
.branch-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
.fa {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--mine {
|
|
||||||
background: #dcfce7;
|
|
||||||
color: #166534;
|
|
||||||
border-color: #4ade80;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--other-0 {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
border-color: #fca5a5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--other-1 {
|
|
||||||
background: #fca5a5;
|
|
||||||
color: #7f1d1d;
|
|
||||||
border-color: #f87171;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--other-2 {
|
|
||||||
background: #dc2626;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #b91c1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--other-3 {
|
|
||||||
background: #991b1b;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #7f1d1d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.branch-chip__home {
|
|
||||||
padding: 0 6px;
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
/* fixed colours: the chip text turns white on the dark reds, which would vanish against this light tag */
|
|
||||||
background: rgb(255 255 255 / 85%);
|
|
||||||
color: #1f2937;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ import { DateFormatPipe } from '../../../pipes/date-format.pipe';
|
|||||||
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
import { FormatNumberPipe } from '../../../pipes/format-number.pipe';
|
||||||
import { CurrencyPipe } from '@angular/common';
|
import { CurrencyPipe } from '@angular/common';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { AuthenticationService } from '../../../core/authentication/authentication.service';
|
|
||||||
import { ReportsService } from 'app/reports/reports.service';
|
import { ReportsService } from 'app/reports/reports.service';
|
||||||
import { SettingsService } from 'app/settings/settings.service';
|
import { SettingsService } from 'app/settings/settings.service';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
@@ -152,7 +151,6 @@ export class GeneralTabComponent implements OnDestroy {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private reportsService = inject(ReportsService);
|
private reportsService = inject(ReportsService);
|
||||||
private settingsService = inject(SettingsService);
|
private settingsService = inject(SettingsService);
|
||||||
private authenticationService = inject(AuthenticationService);
|
|
||||||
|
|
||||||
/** Open Loan Accounts Columns */
|
/** Open Loan Accounts Columns */
|
||||||
openLoansColumns: string[] = [
|
openLoansColumns: string[] = [
|
||||||
@@ -179,7 +177,6 @@ export class GeneralTabComponent implements OnDestroy {
|
|||||||
openSavingsColumns: string[] = [
|
openSavingsColumns: string[] = [
|
||||||
'Account No',
|
'Account No',
|
||||||
'Saving Account',
|
'Saving Account',
|
||||||
'Branch',
|
|
||||||
'Last Active',
|
'Last Active',
|
||||||
'Balance',
|
'Balance',
|
||||||
'Actions'
|
'Actions'
|
||||||
@@ -188,14 +185,12 @@ export class GeneralTabComponent implements OnDestroy {
|
|||||||
closedSavingsColumns: string[] = [
|
closedSavingsColumns: string[] = [
|
||||||
'Account No',
|
'Account No',
|
||||||
'Saving Account',
|
'Saving Account',
|
||||||
'Branch',
|
|
||||||
'Closed Date'
|
'Closed Date'
|
||||||
];
|
];
|
||||||
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
|
/** Closed Recurring Deposit Accounts Columns - with how each one ended, for tracking. */
|
||||||
recurringColumns: string[] = [
|
recurringColumns: string[] = [
|
||||||
'Account No',
|
'Account No',
|
||||||
'Saving Account',
|
'Saving Account',
|
||||||
'Branch',
|
|
||||||
'Status',
|
'Status',
|
||||||
'Last Active',
|
'Last Active',
|
||||||
'Balance',
|
'Balance',
|
||||||
@@ -341,76 +336,12 @@ export class GeneralTabComponent implements OnDestroy {
|
|||||||
/** Client Id */
|
/** Client Id */
|
||||||
clientid: any;
|
clientid: any;
|
||||||
|
|
||||||
/** The customer's own (home) branch, to tell it from branches the customer banks at elsewhere. */
|
|
||||||
clientOfficeId: number | null = null;
|
|
||||||
|
|
||||||
/** How many red shades the branch chips have (general-tab.component.scss, .branch-chip--other-0 ... -3), light to dark. */
|
|
||||||
private static readonly OTHER_BRANCH_SHADES = 4;
|
|
||||||
|
|
||||||
/** The branch the signed-in user works at: accounts held here read green, every other branch red. */
|
|
||||||
private get myOfficeId(): number | null {
|
|
||||||
const officeId = this.authenticationService.getCredentials()?.officeId;
|
|
||||||
return officeId == null ? null : Number(officeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The other branches this customer banks at, lightest to darkest. Ordered by branch id so the shades stay put between
|
|
||||||
* visits; computed over every deposit account on the page so one branch has the same shade in every table.
|
|
||||||
*/
|
|
||||||
private otherBranchIds(): number[] {
|
|
||||||
const mine = this.myOfficeId;
|
|
||||||
const ids = new Set<number>();
|
|
||||||
for (const account of [
|
|
||||||
...this.savingAccounts,
|
|
||||||
...this.currentAccounts
|
|
||||||
]) {
|
|
||||||
const officeId = Number(account?.officeId);
|
|
||||||
if (Number.isFinite(officeId) && officeId !== mine) {
|
|
||||||
ids.add(officeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...ids].sort((a, b) => a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Green for an account at the signed-in user's branch; red for any other branch, a different shade for each. */
|
|
||||||
branchChipClass(account: any): string {
|
|
||||||
const officeId = Number(account?.officeId);
|
|
||||||
if (officeId === this.myOfficeId) {
|
|
||||||
return 'branch-chip branch-chip--mine';
|
|
||||||
}
|
|
||||||
const others = this.otherBranchIds();
|
|
||||||
const rank = Math.max(others.indexOf(officeId), 0);
|
|
||||||
const last = GeneralTabComponent.OTHER_BRANCH_SHADES - 1;
|
|
||||||
// one other branch gets a mid red, several are spread from the lightest to the darkest
|
|
||||||
const shade = others.length <= 1 ? 1 : Math.round((rank * last) / (others.length - 1));
|
|
||||||
return `branch-chip branch-chip--other-${shade}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
branchChipTitle(account: any): string {
|
|
||||||
const parts = [account?.officeName];
|
|
||||||
if (Number(account?.officeId) === this.myOfficeId) {
|
|
||||||
parts.push('your branch');
|
|
||||||
}
|
|
||||||
if (this.isHomeBranch(account)) {
|
|
||||||
parts.push('customer home branch');
|
|
||||||
}
|
|
||||||
return parts.join(' - ');
|
|
||||||
}
|
|
||||||
|
|
||||||
isHomeBranch(account: any): boolean {
|
|
||||||
return this.clientOfficeId != null && Number(account?.officeId) === this.clientOfficeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {ActivatedRoute} route Activated Route
|
* @param {ActivatedRoute} route Activated Route
|
||||||
* @param {ClientsService} clientService Clients Service
|
* @param {ClientsService} clientService Clients Service
|
||||||
* @param {Router} router Router
|
* @param {Router} router Router
|
||||||
*/
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
// The customer's record is resolved on the parent route, not on this tab's own route data.
|
|
||||||
this.route.parent?.data.subscribe((data: any) => {
|
|
||||||
this.clientOfficeId = data?.clientViewData?.officeId ?? null;
|
|
||||||
});
|
|
||||||
this.route.data.subscribe(
|
this.route.data.subscribe(
|
||||||
(data: { clientAccountsData: any; clientChargesData: any; clientSummary: any; clientCollateralData: any }) => {
|
(data: { clientAccountsData: any; clientChargesData: any; clientSummary: any; clientCollateralData: any }) => {
|
||||||
this.clientAccountData = data.clientAccountsData;
|
this.clientAccountData = data.clientAccountsData;
|
||||||
|
|||||||
@@ -50,17 +50,6 @@
|
|||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
@if (isHeadOffice) {
|
|
||||||
<mat-form-field class="table-filter" appearance="outline">
|
|
||||||
<mat-select [(value)]="officeFilter" (selectionChange)="officeChanged()" placeholder="Branch">
|
|
||||||
<mat-option [value]="null">All Branches</mat-option>
|
|
||||||
@for (office of offices; track office.id) {
|
|
||||||
<mat-option [value]="office.id">{{ office.name }}</mat-option>
|
|
||||||
}
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
|
||||||
}
|
|
||||||
|
|
||||||
<mat-form-field class="table-filter" appearance="outline">
|
<mat-form-field class="table-filter" appearance="outline">
|
||||||
<mat-select [(value)]="statusFilter" (selectionChange)="applyClientFilters()" placeholder="Status">
|
<mat-select [(value)]="statusFilter" (selectionChange)="applyClientFilters()" placeholder="Status">
|
||||||
<mat-option value="">All Status</mat-option>
|
<mat-option value="">All Status</mat-option>
|
||||||
@@ -103,11 +92,6 @@
|
|||||||
row.legalForm?.id === 2 ? 'business' : 'person'
|
row.legalForm?.id === 2 ? 'business' : 'person'
|
||||||
}}</mat-icon>
|
}}</mat-icon>
|
||||||
{{ hideClientData ? maskName(row.displayName) : row.displayName }}
|
{{ hideClientData ? maskName(row.displayName) : row.displayName }}
|
||||||
@if (!isHeadOffice && row.officeId && myOfficeId !== null && row.officeId !== myOfficeId) {
|
|
||||||
<span class="row-home-chip" matTooltip="Registered at another branch; linked to this branch">
|
|
||||||
Home: {{ row.officeName }}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
@if (row._ckycDone) {
|
@if (row._ckycDone) {
|
||||||
<mat-icon class="row-ckyc-icon" matTooltip="CKYC Done">verified</mat-icon>
|
<mat-icon class="row-ckyc-icon" matTooltip="CKYC Done">verified</mat-icon>
|
||||||
}
|
}
|
||||||
@@ -130,12 +114,6 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Branch Column (Head Office only) -->
|
|
||||||
<ng-container matColumnDef="officeName">
|
|
||||||
<th mat-header-cell *matHeaderCellDef>Branch</th>
|
|
||||||
<td mat-cell *matCellDef="let row">{{ row.officeName }}</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<!-- Status Column -->
|
<!-- Status Column -->
|
||||||
<ng-container matColumnDef="status">
|
<ng-container matColumnDef="status">
|
||||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Status' | translate }}</th>
|
||||||
|
|||||||
@@ -7,15 +7,3 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@use '../shared/styles/list-layout' as *;
|
@use '../shared/styles/list-layout' as *;
|
||||||
|
|
||||||
// A customer registered at another branch that has been linked to this one.
|
|
||||||
.row-home-chip {
|
|
||||||
margin-left: 8px;
|
|
||||||
padding: 1px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #92400e;
|
|
||||||
background: #fef3c7;
|
|
||||||
border-radius: 999px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ import { MatIconButton, MatButton } from '@angular/material/button';
|
|||||||
import { MatIcon } from '@angular/material/icon';
|
import { MatIcon } from '@angular/material/icon';
|
||||||
import { MatTooltip } from '@angular/material/tooltip';
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||||
import { AuthenticationService } from '../core/authentication/authentication.service';
|
|
||||||
import { OrganizationService } from '../organization/organization.service';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'mifosx-clients',
|
selector: 'mifosx-clients',
|
||||||
@@ -73,15 +71,6 @@ import { OrganizationService } from '../organization/organization.service';
|
|||||||
})
|
})
|
||||||
export class ClientsComponent implements OnInit {
|
export class ClientsComponent implements OnInit {
|
||||||
private clientService = inject(ClientsService);
|
private clientService = inject(ClientsService);
|
||||||
private authenticationService = inject(AuthenticationService);
|
|
||||||
private organizationService = inject(OrganizationService);
|
|
||||||
|
|
||||||
/** Head Office sees every branch's clients, so it gets a Branch filter. A branch user only ever sees its own. */
|
|
||||||
isHeadOffice = this.authenticationService.isHeadOfficeUser();
|
|
||||||
offices: { id: number; name: string }[] = [];
|
|
||||||
officeFilter: number | null = null;
|
|
||||||
/** The caller's own branch, to tell a customer of another branch (linked here) from this branch's own. */
|
|
||||||
myOfficeId = this.authenticationService.getCredentials()?.officeId ?? null;
|
|
||||||
|
|
||||||
/** Returns true if client data masking is enabled */
|
/** Returns true if client data masking is enabled */
|
||||||
get hideClientData(): boolean {
|
get hideClientData(): boolean {
|
||||||
@@ -101,7 +90,6 @@ export class ClientsComponent implements OnInit {
|
|||||||
'displayName',
|
'displayName',
|
||||||
'cifNumber',
|
'cifNumber',
|
||||||
'legalForm',
|
'legalForm',
|
||||||
'officeName',
|
|
||||||
'status',
|
'status',
|
||||||
'profileCompletion',
|
'profileCompletion',
|
||||||
'actions'
|
'actions'
|
||||||
@@ -131,13 +119,6 @@ export class ClientsComponent implements OnInit {
|
|||||||
@ViewChild(MatSort) sort: MatSort;
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (!this.isHeadOffice) {
|
|
||||||
this.displayedColumns = this.displayedColumns.filter((c) => c !== 'officeName');
|
|
||||||
} else {
|
|
||||||
this.organizationService.getOffices().subscribe((offices: any[]) => {
|
|
||||||
this.offices = (offices || []).map((o) => ({ id: o.id, name: o.name }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (environment.preloadClients) {
|
if (environment.preloadClients) {
|
||||||
this.getClients();
|
this.getClients();
|
||||||
}
|
}
|
||||||
@@ -163,8 +144,7 @@ export class ClientsComponent implements OnInit {
|
|||||||
this.sortDirection,
|
this.sortDirection,
|
||||||
false,
|
false,
|
||||||
'',
|
'',
|
||||||
true,
|
true
|
||||||
this.isHeadOffice ? this.officeFilter : null
|
|
||||||
)
|
)
|
||||||
.subscribe(
|
.subscribe(
|
||||||
(data: any) => {
|
(data: any) => {
|
||||||
@@ -253,12 +233,6 @@ export class ClientsComponent implements OnInit {
|
|||||||
this.dataSource.data = filtered;
|
this.dataSource.data = filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Branch filter is applied by the server so paging and the record count stay correct. */
|
|
||||||
officeChanged() {
|
|
||||||
this.resetPaginator();
|
|
||||||
this.getClients();
|
|
||||||
}
|
|
||||||
|
|
||||||
pageChanged(event: PageEvent) {
|
pageChanged(event: PageEvent) {
|
||||||
this.pageSize = event.pageSize;
|
this.pageSize = event.pageSize;
|
||||||
this.currentPage = event.pageIndex;
|
this.currentPage = event.pageIndex;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { map, catchError } from 'rxjs/operators';
|
|||||||
|
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
import { dataUrlToFile } from 'app/shared/utils/data-url.util';
|
||||||
import { ClientIdentityLookupResult } from './models/client-identity.model';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clients service.
|
* Clients service.
|
||||||
@@ -743,14 +742,9 @@ export class ClientsService {
|
|||||||
sortDirection: string = '',
|
sortDirection: string = '',
|
||||||
activeOnly: boolean = false,
|
activeOnly: boolean = false,
|
||||||
legalFormType: string = '',
|
legalFormType: string = '',
|
||||||
silent: boolean = false,
|
silent: boolean = false
|
||||||
officeId: number | null = null
|
|
||||||
) {
|
) {
|
||||||
const innerRequest: any = { text };
|
const innerRequest: any = { text };
|
||||||
// Branch filter (Head Office list). The server still limits results to the caller's office hierarchy.
|
|
||||||
if (officeId != null) {
|
|
||||||
innerRequest.officeId = officeId;
|
|
||||||
}
|
|
||||||
if (activeOnly) {
|
if (activeOnly) {
|
||||||
innerRequest.activeOnly = true;
|
innerRequest.activeOnly = true;
|
||||||
}
|
}
|
||||||
@@ -846,11 +840,6 @@ export class ClientsService {
|
|||||||
return this.http.post('/kyc/risk-rules', rule);
|
return this.http.post('/kyc/risk-rules', rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ends the trial of a newly added rule; it stays in the catalog as a normal rule. */
|
|
||||||
markClientRiskRuleNormal(ruleId: number): Observable<any> {
|
|
||||||
return this.http.post(`/kyc/risk-rules/${ruleId}/mark-normal`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch code values by code ID */
|
/** Fetch code values by code ID */
|
||||||
getCodeValuesById(codeId: number): Observable<any[]> {
|
getCodeValuesById(codeId: number): Observable<any[]> {
|
||||||
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
return this.http.get<any[]>(`/codes/${codeId}/codevalues`).pipe(catchError(() => of([])));
|
||||||
@@ -879,39 +868,6 @@ export class ClientsService {
|
|||||||
return this.http.post(`/clients/${clientId}/kyc`, kycData);
|
return this.http.post(`/clients/${clientId}/kyc`, kycData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* "Is this person already a customer of the bank?" — across every branch, by PAN, Aadhaar fingerprint or GSTIN.
|
|
||||||
* Any ONE of them matching means the same customer. `contactConflicts` reports a phone/email already used by another
|
|
||||||
* client, which is never the same customer. Only the Aadhaar's SHA-256 is sent, never the number.
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Introduces a customer of another branch to the caller's branch, so the branch can see them and open an account
|
|
||||||
* for them there. The customer stays at the home branch. Safe to repeat.
|
|
||||||
*/
|
|
||||||
linkClientToMyBranch(
|
|
||||||
clientId: number
|
|
||||||
): Observable<{ clientId: number; linked: boolean; officeId: number; officeName: string }> {
|
|
||||||
return this.http.post<{ clientId: number; linked: boolean; officeId: number; officeName: string }>(
|
|
||||||
'/clientidentity/link',
|
|
||||||
{
|
|
||||||
clientId
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
lookupClientIdentity(identity: {
|
|
||||||
panNumber?: string | null;
|
|
||||||
aadhaarHash?: string | null;
|
|
||||||
gstinNumber?: string | null;
|
|
||||||
mobileNo?: string | null;
|
|
||||||
emailAddress?: string | null;
|
|
||||||
excludeClientId?: number | null;
|
|
||||||
}): Observable<ClientIdentityLookupResult> {
|
|
||||||
return this.http.post<ClientIdentityLookupResult>('/clientidentity/lookup', identity, {
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen a client name against the UN Security Council consolidated sanctions list.
|
* Screen a client name against the UN Security Council consolidated sanctions list.
|
||||||
* @param name The name to screen
|
* @param name The name to screen
|
||||||
@@ -1117,91 +1073,9 @@ export class ClientsService {
|
|||||||
*/
|
*/
|
||||||
saveKycContactLog(
|
saveKycContactLog(
|
||||||
clientId: string | number,
|
clientId: string | number,
|
||||||
data: { channel: string; notes: string; officerName?: string; noticeType?: string },
|
data: { channel: string; notes: string; officerName?: string }
|
||||||
silent = false
|
|
||||||
): Observable<any> {
|
): Observable<any> {
|
||||||
return this.http.post(
|
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
|
||||||
`/kyc/exceptions/${clientId}/contact-log`,
|
|
||||||
data,
|
|
||||||
silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The subject and text a customer would receive for the stage they are at today.
|
|
||||||
* `type` is KYC or DORMANCY; the body comes back empty when they are at no such stage.
|
|
||||||
*/
|
|
||||||
previewKycNotice(clientId: string | number, type: string): Observable<{ subject?: string; body?: string }> {
|
|
||||||
return this.http.get<{ subject?: string; body?: string }>(
|
|
||||||
`/kyc/exceptions/${clientId}/contact-log/preview?type=${type}`,
|
|
||||||
{
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Queues notices to be sent in the background by the queue job. */
|
|
||||||
enqueueKycNotices(body: {
|
|
||||||
items: { clientId: number; noticeType: string; channel: string; accountNo?: string; stageLabel?: string }[];
|
|
||||||
notes?: string;
|
|
||||||
officerName?: string;
|
|
||||||
sendAt?: string;
|
|
||||||
}): 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. */
|
|
||||||
getKycNoticeQueue(status = ''): Observable<any[]> {
|
|
||||||
return this.http.get<any[]>(`/kyc/notice-queue${status ? `?status=${status}` : ''}`, {
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
actOnKycNotice(id: number, command: 'cancel' | 'retry'): Observable<any> {
|
|
||||||
return this.http.post(`/kyc/notice-queue/${id}?command=${command}`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Notices per day (customers, notices, outcomes), newest day first. */
|
|
||||||
getKycContactAuditDays(
|
|
||||||
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),
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Customer notices on record, newest first, a page at a time. */
|
|
||||||
getKycContactAudit(
|
|
||||||
filter: {
|
|
||||||
from?: string;
|
|
||||||
to?: string;
|
|
||||||
channel?: string;
|
|
||||||
status?: string;
|
|
||||||
search?: string;
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
} = {}
|
|
||||||
): Observable<{ total: number; rows: any[] }> {
|
|
||||||
return this.http.get<{ total: number; rows: any[] }>('/kyc/contact-audit', {
|
|
||||||
params: this.auditParams(filter),
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private auditParams(filter: Record<string, string | number | undefined>): HttpParams {
|
|
||||||
let params = new HttpParams();
|
|
||||||
Object.entries(filter).forEach(
|
|
||||||
([
|
|
||||||
k,
|
|
||||||
v
|
|
||||||
]) => {
|
|
||||||
if (v !== undefined && v !== null && v !== '') {
|
|
||||||
params = params.set(k, String(v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return params;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getKycContactLog(clientId: string | number): Observable<any[]> {
|
getKycContactLog(clientId: string | number): Observable<any[]> {
|
||||||
@@ -1218,14 +1092,6 @@ export class ClientsService {
|
|||||||
return this.http.get<any[]>('/kyc/exceptions/lapsed');
|
return this.http.get<any[]>('/kyc/exceptions/lapsed');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clients whose re-KYC falls due within the next `days` days (default 30), soonest first. */
|
|
||||||
getExpiringKyc(days = 30): Observable<any[]> {
|
|
||||||
// The caller reports a failure in its own banner, so the global popup is suppressed.
|
|
||||||
return this.http.get<any[]>(`/kyc/exceptions/expiring?days=${days}`, {
|
|
||||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Savings accounts the dormancy job has marked.
|
* Savings accounts the dormancy job has marked.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -38,12 +38,6 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!createdClientId) {
|
|
||||||
<mifosx-client-identity-check
|
|
||||||
[legalFormType]="legalFormType"
|
|
||||||
(identityChange)="onIdentityChange($event)"
|
|
||||||
></mifosx-client-identity-check>
|
|
||||||
}
|
|
||||||
<mifosx-client-general-step
|
<mifosx-client-general-step
|
||||||
[clientTemplate]="clientTemplate"
|
[clientTemplate]="clientTemplate"
|
||||||
[prefillData]="prefillData"
|
[prefillData]="prefillData"
|
||||||
@@ -85,7 +79,6 @@
|
|||||||
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
[verificationDateOfBirth]="clientGeneralStep?.verificationDateOfBirth || null"
|
||||||
[readOnly]="isTabReadOnly(1)"
|
[readOnly]="isTabReadOnly(1)"
|
||||||
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
[calculatedRisk]="clientGeneralStep?.calculatedRisk"
|
||||||
[identityPrefill]="identity"
|
|
||||||
></mifosx-client-kyc-step>
|
></mifosx-client-kyc-step>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ import { ClientIndiaAddressStepComponent } from '../client-stepper/client-india-
|
|||||||
import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component';
|
import { ClientEntityKycStepComponent } from '../client-stepper/client-entity-kyc-step/client-entity-kyc-step.component';
|
||||||
import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component';
|
import { ClientDatatableStepComponent } from '../client-stepper/client-datatable-step/client-datatable-step.component';
|
||||||
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
|
import { ClientKycStepComponent } from '../client-stepper/client-kyc-step/client-kyc-step.component';
|
||||||
import {
|
|
||||||
ClientIdentityCheckComponent,
|
|
||||||
ClientIdentityState
|
|
||||||
} from '../client-stepper/client-identity-check/client-identity-check.component';
|
|
||||||
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
|
import { ClientPreviewStepComponent } from '../client-stepper/client-preview-step/client-preview-step.component';
|
||||||
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
|
import { ClientFamilyMembersStepComponent } from '../client-stepper/client-family-members-step/client-family-members-step.component';
|
||||||
|
|
||||||
@@ -64,7 +60,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
|||||||
ClientEntityKycStepComponent,
|
ClientEntityKycStepComponent,
|
||||||
ClientDatatableStepComponent,
|
ClientDatatableStepComponent,
|
||||||
ClientKycStepComponent,
|
ClientKycStepComponent,
|
||||||
ClientIdentityCheckComponent,
|
|
||||||
ClientPreviewStepComponent,
|
ClientPreviewStepComponent,
|
||||||
ClientFamilyMembersStepComponent,
|
ClientFamilyMembersStepComponent,
|
||||||
MatProgressBarModule,
|
MatProgressBarModule,
|
||||||
@@ -84,7 +79,6 @@ export class CreateClientComponent {
|
|||||||
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
|
@ViewChild('clientIndiaAddress') clientIndiaAddressStep: ClientIndiaAddressStepComponent;
|
||||||
@ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
|
@ViewChild(ClientEntityKycStepComponent) clientEntityKycStep: ClientEntityKycStepComponent;
|
||||||
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
|
@ViewChild('clientKyc') clientKycStep: ClientKycStepComponent;
|
||||||
@ViewChild(ClientIdentityCheckComponent) identityCheck: ClientIdentityCheckComponent;
|
|
||||||
@ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
@ViewChild(ClientFamilyMembersStepComponent) clientFamilyMembersStep: ClientFamilyMembersStepComponent;
|
||||||
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
|
@ViewChildren('dtclient') clientDatatables: QueryList<ClientDatatableStepComponent>;
|
||||||
|
|
||||||
@@ -93,17 +87,6 @@ export class CreateClientComponent {
|
|||||||
selectedTabIndex = 0;
|
selectedTabIndex = 0;
|
||||||
createdClientId = '';
|
createdClientId = '';
|
||||||
savingClient = false;
|
savingClient = false;
|
||||||
/**
|
|
||||||
* Result of the existing-customer check at the top of the General step. While it says the customer already exists
|
|
||||||
* (or the identifiers disagree) a new client must not be created; otherwise the identifiers typed there are filled
|
|
||||||
* into the KYC step.
|
|
||||||
*/
|
|
||||||
identity: ClientIdentityState | null = null;
|
|
||||||
|
|
||||||
onIdentityChange(state: ClientIdentityState): void {
|
|
||||||
this.identity = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pre-fill data passed from "Create as Client" on a manual family member */
|
/** Pre-fill data passed from "Create as Client" on a manual family member */
|
||||||
prefillData: any = null;
|
prefillData: any = null;
|
||||||
|
|
||||||
@@ -155,8 +138,6 @@ export class CreateClientComponent {
|
|||||||
this.highestTabReached = 0;
|
this.highestTabReached = 0;
|
||||||
this.validationError = '';
|
this.validationError = '';
|
||||||
this.prefillData = null;
|
this.prefillData = null;
|
||||||
this.identity = null;
|
|
||||||
this.identityCheck?.reset();
|
|
||||||
this.clientGeneralStep?.resetForNewClient();
|
this.clientGeneralStep?.resetForNewClient();
|
||||||
this.clientKycStep?.resetForNewClient();
|
this.clientKycStep?.resetForNewClient();
|
||||||
this.clientIndiaAddressStep?.resetForNewClient();
|
this.clientIndiaAddressStep?.resetForNewClient();
|
||||||
@@ -231,10 +212,6 @@ export class CreateClientComponent {
|
|||||||
const idx = this.selectedTabIndex;
|
const idx = this.selectedTabIndex;
|
||||||
|
|
||||||
if (idx === 0) {
|
if (idx === 0) {
|
||||||
// The customer is already on the books (any branch), or the identifiers belong to different customers.
|
|
||||||
if (!this.createdClientId && (this.identity?.blocking || this.identity?.status === 'checking')) return false;
|
|
||||||
// A PAN / Aadhaar / GSTIN typed in the wrong format is neither looked up nor carried on, so it must be fixed or cleared.
|
|
||||||
if (!this.createdClientId && this.identityCheck?.hasInvalidInput) return false;
|
|
||||||
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
if (!this.clientGeneralStep || !this.clientGeneralForm) return false;
|
||||||
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
if (this.clientGeneralStep.sanctionsBlocked) return false;
|
||||||
// A number the officer rejected on the DIP result must be replaced, not carried forward.
|
// A number the officer rejected on the DIP result must be replaced, not carried forward.
|
||||||
@@ -297,17 +274,6 @@ export class CreateClientComponent {
|
|||||||
if (this.savingClient) return 'Saving — please wait…';
|
if (this.savingClient) return 'Saving — please wait…';
|
||||||
if (this.currentStepValid) return '';
|
if (this.currentStepValid) return '';
|
||||||
const idx = this.selectedTabIndex;
|
const idx = this.selectedTabIndex;
|
||||||
if (idx === 0 && !this.createdClientId && this.identityCheck?.hasInvalidInput) {
|
|
||||||
return 'Correct the PAN / Aadhaar / GSTIN format in the Existing customer check, or clear the field.';
|
|
||||||
}
|
|
||||||
if (idx === 0 && !this.createdClientId && this.identity?.status === 'checking') {
|
|
||||||
return 'Checking whether this customer already exists…';
|
|
||||||
}
|
|
||||||
if (idx === 0 && !this.createdClientId && this.identity?.blocking) {
|
|
||||||
return this.identity.status === 'conflict'
|
|
||||||
? 'The PAN / Aadhaar / GSTIN entered belong to different customers — correct them or ask a manager.'
|
|
||||||
: 'This customer already exists — a new client is not created for the same person. Open the existing customer instead.';
|
|
||||||
}
|
|
||||||
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
if (idx === 0 && this.clientGeneralStep?.sanctionsBlocked) {
|
||||||
const disposition = this.clientGeneralStep.sanctionsDisposition;
|
const disposition = this.clientGeneralStep.sanctionsDisposition;
|
||||||
if (disposition === 'FALSE_POSITIVE') {
|
if (disposition === 'FALSE_POSITIVE') {
|
||||||
@@ -392,19 +358,6 @@ export class CreateClientComponent {
|
|||||||
onNext() {
|
onNext() {
|
||||||
// If on General tab and client not yet created — save first
|
// If on General tab and client not yet created — save first
|
||||||
if (this.selectedTabIndex === 0 && !this.createdClientId) {
|
if (this.selectedTabIndex === 0 && !this.createdClientId) {
|
||||||
// Defensive re-check: the button is disabled while this holds, but a second client for the same person is the
|
|
||||||
// one mistake this screen exists to prevent.
|
|
||||||
if (this.identityCheck?.hasInvalidInput) {
|
|
||||||
this.identityCheck.markAllAsTouched();
|
|
||||||
this.snackBar.open(this.currentStepBlockerMessage, 'Close', { duration: 6000 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.identity?.blocking) {
|
|
||||||
this.snackBar.open(this.currentStepBlockerMessage || 'This customer already exists.', 'Close', {
|
|
||||||
duration: 6000
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.clientGeneralForm?.valid) {
|
if (!this.clientGeneralForm?.valid) {
|
||||||
this.clientGeneralForm?.markAllAsTouched();
|
this.clientGeneralForm?.markAllAsTouched();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -1,34 +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/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** An existing customer found by PAN / Aadhaar fingerprint / GSTIN. Identifiers are masked by the server. */
|
|
||||||
export interface ClientIdentityMatch {
|
|
||||||
clientId: number;
|
|
||||||
displayName: string;
|
|
||||||
cifNumber: string;
|
|
||||||
officeId: number;
|
|
||||||
officeName: string;
|
|
||||||
legalFormEnum: number | null;
|
|
||||||
kycStatus: number;
|
|
||||||
/** True when the caller's branch can open this client; false when it sits in another branch. */
|
|
||||||
visibleToCaller: boolean;
|
|
||||||
panMasked: string | null;
|
|
||||||
aadhaarMasked: string | null;
|
|
||||||
gstinMasked: string | null;
|
|
||||||
matchedBy: Array<'PAN' | 'AADHAAR' | 'GSTIN'>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClientIdentityLookupResult {
|
|
||||||
/** Any ONE of PAN / Aadhaar / GSTIN matched an existing customer: the same person or entity. */
|
|
||||||
matched: boolean;
|
|
||||||
/** The identifiers point at different customers: bad data or fraud, not a customer to reuse. */
|
|
||||||
conflict: boolean;
|
|
||||||
matches: ClientIdentityMatch[];
|
|
||||||
/** A phone or email already used by another client. Never means "the same customer". */
|
|
||||||
contactConflicts: { mobileNo: boolean; emailAddress: boolean };
|
|
||||||
}
|
|
||||||
@@ -1,52 +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 { createHash } from 'crypto';
|
|
||||||
import { aadhaarHash, sha256Hex } from './aadhaar-hash.util';
|
|
||||||
|
|
||||||
describe('sha256Hex', () => {
|
|
||||||
it('matches the published SHA-256 test vectors', () => {
|
|
||||||
expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855');
|
|
||||||
expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
|
|
||||||
expect(sha256Hex('abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq')).toBe(
|
|
||||||
'248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('agrees with Node on 12-digit inputs, including ones that need a second block boundary', () => {
|
|
||||||
for (const value of [
|
|
||||||
'000000000000',
|
|
||||||
'123456789012',
|
|
||||||
'999999999999',
|
|
||||||
'0'.repeat(55),
|
|
||||||
'0'.repeat(56),
|
|
||||||
'0'.repeat(64)
|
|
||||||
]) {
|
|
||||||
expect(sha256Hex(value)).toBe(createHash('sha256').update(value).digest('hex'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('aadhaarHash', () => {
|
|
||||||
it('hashes a 12-digit number', () => {
|
|
||||||
expect(aadhaarHash('123456789012')).toBe(createHash('sha256').update('123456789012').digest('hex'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores the spaces and hyphens people type between the groups', () => {
|
|
||||||
expect(aadhaarHash('1234 5678 9012')).toBe(aadhaarHash('123456789012'));
|
|
||||||
expect(aadhaarHash('1234-5678-9012')).toBe(aadhaarHash('123456789012'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for anything that is not a full number', () => {
|
|
||||||
expect(aadhaarHash('')).toBeNull();
|
|
||||||
expect(aadhaarHash(null)).toBeNull();
|
|
||||||
expect(aadhaarHash('XXXXXXXX1234')).toBeNull();
|
|
||||||
expect(aadhaarHash('XXXXXXXX0000')).toBeNull();
|
|
||||||
expect(aadhaarHash('12345678901')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,185 +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 comparable fingerprint of an Aadhaar number: SHA-256 of the 12 digits, as 64 lowercase hex characters.
|
|
||||||
*
|
|
||||||
* Only this fingerprint is sent to the server. The KYC create/update calls are recorded in an audit table and a log
|
|
||||||
* on the server, so the 12-digit number must not be in the request.
|
|
||||||
*
|
|
||||||
* A plain, unkeyed SHA-256 of 12 digits can be reversed by trying every number, so this is a lookup key and not
|
|
||||||
* protection against someone who can read the stored value.
|
|
||||||
*
|
|
||||||
* Written out in full instead of using `crypto.subtle` because that API is asynchronous and only exists in secure
|
|
||||||
* contexts, while the callers (a form-value getter, a blur handler) need the answer synchronously and the app may be
|
|
||||||
* served over plain HTTP.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const K = [
|
|
||||||
0x428a2f98,
|
|
||||||
0x71374491,
|
|
||||||
0xb5c0fbcf,
|
|
||||||
0xe9b5dba5,
|
|
||||||
0x3956c25b,
|
|
||||||
0x59f111f1,
|
|
||||||
0x923f82a4,
|
|
||||||
0xab1c5ed5,
|
|
||||||
0xd807aa98,
|
|
||||||
0x12835b01,
|
|
||||||
0x243185be,
|
|
||||||
0x550c7dc3,
|
|
||||||
0x72be5d74,
|
|
||||||
0x80deb1fe,
|
|
||||||
0x9bdc06a7,
|
|
||||||
0xc19bf174,
|
|
||||||
0xe49b69c1,
|
|
||||||
0xefbe4786,
|
|
||||||
0x0fc19dc6,
|
|
||||||
0x240ca1cc,
|
|
||||||
0x2de92c6f,
|
|
||||||
0x4a7484aa,
|
|
||||||
0x5cb0a9dc,
|
|
||||||
0x76f988da,
|
|
||||||
0x983e5152,
|
|
||||||
0xa831c66d,
|
|
||||||
0xb00327c8,
|
|
||||||
0xbf597fc7,
|
|
||||||
0xc6e00bf3,
|
|
||||||
0xd5a79147,
|
|
||||||
0x06ca6351,
|
|
||||||
0x14292967,
|
|
||||||
0x27b70a85,
|
|
||||||
0x2e1b2138,
|
|
||||||
0x4d2c6dfc,
|
|
||||||
0x53380d13,
|
|
||||||
0x650a7354,
|
|
||||||
0x766a0abb,
|
|
||||||
0x81c2c92e,
|
|
||||||
0x92722c85,
|
|
||||||
0xa2bfe8a1,
|
|
||||||
0xa81a664b,
|
|
||||||
0xc24b8b70,
|
|
||||||
0xc76c51a3,
|
|
||||||
0xd192e819,
|
|
||||||
0xd6990624,
|
|
||||||
0xf40e3585,
|
|
||||||
0x106aa070,
|
|
||||||
0x19a4c116,
|
|
||||||
0x1e376c08,
|
|
||||||
0x2748774c,
|
|
||||||
0x34b0bcb5,
|
|
||||||
0x391c0cb3,
|
|
||||||
0x4ed8aa4a,
|
|
||||||
0x5b9cca4f,
|
|
||||||
0x682e6ff3,
|
|
||||||
0x748f82ee,
|
|
||||||
0x78a5636f,
|
|
||||||
0x84c87814,
|
|
||||||
0x8cc70208,
|
|
||||||
0x90befffa,
|
|
||||||
0xa4506ceb,
|
|
||||||
0xbef9a3f7,
|
|
||||||
0xc67178f2
|
|
||||||
];
|
|
||||||
|
|
||||||
function rotr(x: number, n: number): number {
|
|
||||||
return (x >>> n) | (x << (32 - n));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SHA-256 of a string (UTF-8 encoded), as lowercase hex. */
|
|
||||||
export function sha256Hex(text: string): string {
|
|
||||||
const bytes: number[] = [];
|
|
||||||
for (const ch of unescape(encodeURIComponent(text))) {
|
|
||||||
bytes.push(ch.charCodeAt(0));
|
|
||||||
}
|
|
||||||
const bitLength = bytes.length * 8;
|
|
||||||
|
|
||||||
bytes.push(0x80);
|
|
||||||
while (bytes.length % 64 !== 56) {
|
|
||||||
bytes.push(0);
|
|
||||||
}
|
|
||||||
// 64-bit big-endian message length; inputs here are tiny so the high word is always 0.
|
|
||||||
const high = Math.floor(bitLength / 0x100000000);
|
|
||||||
const low = bitLength >>> 0;
|
|
||||||
for (const word of [
|
|
||||||
high,
|
|
||||||
low
|
|
||||||
]) {
|
|
||||||
bytes.push((word >>> 24) & 0xff, (word >>> 16) & 0xff, (word >>> 8) & 0xff, word & 0xff);
|
|
||||||
}
|
|
||||||
|
|
||||||
const h = [
|
|
||||||
0x6a09e667,
|
|
||||||
0xbb67ae85,
|
|
||||||
0x3c6ef372,
|
|
||||||
0xa54ff53a,
|
|
||||||
0x510e527f,
|
|
||||||
0x9b05688c,
|
|
||||||
0x1f83d9ab,
|
|
||||||
0x5be0cd19
|
|
||||||
];
|
|
||||||
const w = new Array<number>(64);
|
|
||||||
|
|
||||||
for (let offset = 0; offset < bytes.length; offset += 64) {
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
const j = offset + i * 4;
|
|
||||||
w[i] = (bytes[j] << 24) | (bytes[j + 1] << 16) | (bytes[j + 2] << 8) | bytes[j + 3];
|
|
||||||
}
|
|
||||||
for (let i = 16; i < 64; i++) {
|
|
||||||
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
|
||||||
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
|
||||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let [
|
|
||||||
a,
|
|
||||||
b,
|
|
||||||
c,
|
|
||||||
d,
|
|
||||||
e,
|
|
||||||
f,
|
|
||||||
g,
|
|
||||||
hh
|
|
||||||
] = h;
|
|
||||||
for (let i = 0; i < 64; i++) {
|
|
||||||
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
||||||
const ch = (e & f) ^ (~e & g);
|
|
||||||
const t1 = (hh + S1 + ch + K[i] + w[i]) | 0;
|
|
||||||
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
||||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
||||||
const t2 = (S0 + maj) | 0;
|
|
||||||
hh = g;
|
|
||||||
g = f;
|
|
||||||
f = e;
|
|
||||||
e = (d + t1) | 0;
|
|
||||||
d = c;
|
|
||||||
c = b;
|
|
||||||
b = a;
|
|
||||||
a = (t1 + t2) | 0;
|
|
||||||
}
|
|
||||||
h[0] = (h[0] + a) | 0;
|
|
||||||
h[1] = (h[1] + b) | 0;
|
|
||||||
h[2] = (h[2] + c) | 0;
|
|
||||||
h[3] = (h[3] + d) | 0;
|
|
||||||
h[4] = (h[4] + e) | 0;
|
|
||||||
h[5] = (h[5] + f) | 0;
|
|
||||||
h[6] = (h[6] + g) | 0;
|
|
||||||
h[7] = (h[7] + hh) | 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return h.map((x) => (x >>> 0).toString(16).padStart(8, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The fingerprint for a 12-digit Aadhaar, or null when the value is not one (too short, masked, a placeholder).
|
|
||||||
* Spaces and hyphens people type between the groups are ignored.
|
|
||||||
*/
|
|
||||||
export function aadhaarHash(aadhaar: string | null | undefined): string | null {
|
|
||||||
const digits = (aadhaar ?? '').replace(/[\s-]/g, '');
|
|
||||||
return /^\d{12}$/.test(digits) ? sha256Hex(digits) : null;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { toPersonNameCase, toProperNameCase } from './name-case.util';
|
import { toProperNameCase } from './name-case.util';
|
||||||
|
|
||||||
describe('toProperNameCase', () => {
|
describe('toProperNameCase', () => {
|
||||||
it('raises a lower-case first letter on every word', () => {
|
it('raises a lower-case first letter on every word', () => {
|
||||||
@@ -53,20 +53,3 @@ describe('toProperNameCase', () => {
|
|||||||
expect(toProperNameCase(undefined)).toBe('');
|
expect(toProperNameCase(undefined)).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toPersonNameCase', () => {
|
|
||||||
it('title-cases every word, lowering mixed or upper case', () => {
|
|
||||||
expect(toPersonNameCase('keshav SIngh sisodiya')).toBe('Keshav Singh Sisodiya');
|
|
||||||
expect(toPersonNameCase('KESHAV')).toBe('Keshav');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps hyphen and apostrophe name prefixes', () => {
|
|
||||||
expect(toPersonNameCase('JEAN-marie')).toBe('Jean-Marie');
|
|
||||||
expect(toPersonNameCase("D'SOUZA")).toBe("D'Souza");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an empty string for blank or non-string input', () => {
|
|
||||||
expect(toPersonNameCase(' ')).toBe('');
|
|
||||||
expect(toPersonNameCase(null)).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -62,17 +62,3 @@ export function toProperNameCase(value: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
return collapsed.split(' ').map(capitalizeWord).join(' ');
|
return collapsed.split(' ').map(capitalizeWord).join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Title case for an individual's first, middle and last name: each word's first letter raised,
|
|
||||||
* the rest lowered, so 'SIngh' and 'singh' both become 'Singh'. This is what the Full Name line on
|
|
||||||
* the general step has always shown; using it for the stored value too keeps the preview and the
|
|
||||||
* saved client the same as that line. Entity names stay on {@link toProperNameCase} — their
|
|
||||||
* spelling is matched against the registered PAN name.
|
|
||||||
*/
|
|
||||||
export function toPersonNameCase(value: string | null | undefined): string {
|
|
||||||
if (typeof value !== 'string') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return toProperNameCase(value.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export class CacheInterceptor implements HttpInterceptor {
|
|||||||
'/rbireservereturn',
|
'/rbireservereturn',
|
||||||
'/rbireserveconfig',
|
'/rbireserveconfig',
|
||||||
'/tdsconfig',
|
'/tdsconfig',
|
||||||
'/profitappropriationconfig',
|
|
||||||
'/tds/deposit-interest',
|
'/tds/deposit-interest',
|
||||||
// Declarations and challans are entered on the very screens that read them back.
|
// Declarations and challans are entered on the very screens that read them back.
|
||||||
'/tds/declarations',
|
'/tds/declarations',
|
||||||
@@ -54,12 +53,7 @@ export class CacheInterceptor implements HttpInterceptor {
|
|||||||
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
|
// deposit may be closed at all - a stale copy would show a released lien as still blocking.
|
||||||
'/liens',
|
'/liens',
|
||||||
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
|
// The RD Dashboard counts late installments as of today; a cached copy would hide a payment just taken.
|
||||||
'/recurringdepositaccounts/dashboard',
|
'/recurringdepositaccounts/dashboard'
|
||||||
// Notes are also written by action screens (approve, close, deposit...), so a cached list would hide them.
|
|
||||||
'/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'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
private isCacheable(url: string): boolean {
|
private isCacheable(url: string): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Copyright since 2025 Mifos Initiative
|
||||||
|
*
|
||||||
|
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { HttpContextToken } from '@angular/common/http';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-request opt-out of the top progress bar. Set via `{ context: new HttpContext().set(NO_PROGRESS_BAR, true) }`
|
||||||
|
* on background calls the user did not ask for (such as the Approvals badge listening for changes), which would
|
||||||
|
* otherwise keep the bar running or make it flash on a screen that has not changed.
|
||||||
|
*/
|
||||||
|
export const NO_PROGRESS_BAR = new HttpContextToken<boolean>(() => false);
|
||||||
@@ -16,6 +16,7 @@ import { finalize } from 'rxjs/operators';
|
|||||||
|
|
||||||
/** Custom Services */
|
/** Custom Services */
|
||||||
import { ProgressBarService } from './progress-bar.service';
|
import { ProgressBarService } from './progress-bar.service';
|
||||||
|
import { NO_PROGRESS_BAR } from './no-progress.token';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Http Request interceptor to start/stop loading the progress bar.
|
* Http Request interceptor to start/stop loading the progress bar.
|
||||||
@@ -29,6 +30,9 @@ export class ProgressInterceptor implements HttpInterceptor {
|
|||||||
* and stop when a response or error is received.
|
* and stop when a response or error is received.
|
||||||
*/
|
*/
|
||||||
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||||
|
if (request.context.get(NO_PROGRESS_BAR)) {
|
||||||
|
return next.handle(request);
|
||||||
|
}
|
||||||
this.progressBarService.increase();
|
this.progressBarService.increase();
|
||||||
return next.handle(request).pipe(
|
return next.handle(request).pipe(
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
|
|||||||
@@ -407,15 +407,6 @@
|
|||||||
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
<fa-icon icon="clipboard-check" class="ni-ico"></fa-icon>Loan Origination
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
class="ni"
|
|
||||||
tabindex="0"
|
|
||||||
routerLink="/credit-bureau/dashboard"
|
|
||||||
routerLinkActive="active"
|
|
||||||
*mifosxHasPermission="'MENU_CREDIT_BUREAU'"
|
|
||||||
>
|
|
||||||
<fa-icon icon="id-card" class="ni-ico"></fa-icon>Credit Bureau
|
|
||||||
</a>
|
|
||||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
<a class="ni" tabindex="0" [matMenuTriggerFor]="advancesMenu" *mifosxHasPermission="'MENU_ADVANCES'">
|
||||||
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
<fa-icon icon="hand-holding-usd" class="ni-ico"></fa-icon>Advances
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
@@ -474,6 +465,11 @@
|
|||||||
*mifosxHasPermission="'MENU_TASKS'"
|
*mifosxHasPermission="'MENU_TASKS'"
|
||||||
>
|
>
|
||||||
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
|
<fa-icon icon="check" class="ni-ico"></fa-icon>Approvals
|
||||||
|
@if (approvalTotal > 0) {
|
||||||
|
<span class="ni-badge" title="Requests waiting for approval">{{
|
||||||
|
approvalTotal > 99 ? '99+' : approvalTotal
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
|
<a class="ni" tabindex="0" [matMenuTriggerFor]="cashierMenu" *mifosxHasPermission="'MENU_CASHIER'">
|
||||||
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
|
<fa-icon icon="money-bill" class="ni-ico"></fa-icon>Cashier
|
||||||
@@ -517,16 +513,6 @@
|
|||||||
><span class="ni-short">Loans</span>
|
><span class="ni-short">Loans</span>
|
||||||
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
<fa-icon icon="caret-down" class="ni-caret"></fa-icon>
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
class="ni"
|
|
||||||
tabindex="0"
|
|
||||||
routerLink="/credit-bureau/dashboard"
|
|
||||||
routerLinkActive="active"
|
|
||||||
*mifosxHasPermission="'MENU_CREDIT_BUREAU'"
|
|
||||||
>
|
|
||||||
<fa-icon icon="id-card" class="ni-ico"></fa-icon><span class="ni-full">Credit Bureau</span
|
|
||||||
><span class="ni-short">CIC</span>
|
|
||||||
</a>
|
|
||||||
<a
|
<a
|
||||||
class="ni"
|
class="ni"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -1246,9 +1232,6 @@
|
|||||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/current']">
|
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/current']">
|
||||||
<fa-icon icon="building" class="menu-item-icon"></fa-icon> Current
|
<fa-icon icon="building" class="menu-item-icon"></fa-icon> Current
|
||||||
</button>
|
</button>
|
||||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/cc']">
|
|
||||||
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> CC
|
|
||||||
</button>
|
|
||||||
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/fixed-deposits']">
|
<button mat-menu-item tabindex="0" [routerLink]="['/accounts/fixed-deposits']">
|
||||||
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Fixed Deposit
|
<fa-icon icon="lock" class="menu-item-icon"></fa-icon> Fixed Deposit
|
||||||
</button>
|
</button>
|
||||||
@@ -2058,11 +2041,11 @@
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="gl-link"
|
class="gl-link"
|
||||||
[class.hidden]="!matchesRbiSearch('ALM Average Liabilities and Assets Time Bucket Breakdown')"
|
[class.hidden]="!matchesRbiSearch('ALM')"
|
||||||
(click)="rbiMenuOpen = false; openAlmBucketBreakdownDialog()"
|
(click)="rbiMenuOpen = false; openAlmReportDialog()"
|
||||||
>
|
>
|
||||||
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Average Liabilities
|
<fa-icon icon="balance-scale" class="gl-icon" style="color: #4f46e5"></fa-icon> ALM (Asset Liability
|
||||||
and Assets) – Time Bucket Breakdown
|
Management)
|
||||||
</a>
|
</a>
|
||||||
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
|
<a class="gl-link" [class.hidden]="!matchesRbiSearch('Balance Sheet')" (click)="rbiMenuOpen = false">
|
||||||
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
|
<fa-icon icon="file-alt" class="gl-icon"></fa-icon> Balance Sheet (BR Act & Others)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user