feat(cfcfrms): I4C Live screens for SOP layer chain, risk, bank holds and restorations

- Risk screen and shared I4C date field
- Bank Master: IFSC to I4C bank code map and unmapped IFSC list
- Holds, restorations, grievances, documents, outbox and dashboard updates
  for the backend I4C SOP / outward ledger changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 11:25:40 +05:30
co-authored by Claude Opus 5.5
parent fca550f4c4
commit 96bebd339b
32 changed files with 4368 additions and 302 deletions
@@ -31,6 +31,7 @@ import { I4cIntimationSettingsComponent } from './i4c/i4c-intimation-settings/i4
import { I4cGrievancesComponent } from './i4c/i4c-grievances/i4c-grievances.component'; import { I4cGrievancesComponent } from './i4c/i4c-grievances/i4c-grievances.component';
import { I4cRestorationsComponent } from './i4c/i4c-restorations/i4c-restorations.component'; import { I4cRestorationsComponent } from './i4c/i4c-restorations/i4c-restorations.component';
import { I4cMrmReportComponent } from './i4c/i4c-mrm-report/i4c-mrm-report.component'; import { I4cMrmReportComponent } from './i4c/i4c-mrm-report/i4c-mrm-report.component';
import { I4cRiskComponent } from './i4c/i4c-risk/i4c-risk.component';
const routes: Routes = [ const routes: Routes = [
Route.withShell([ Route.withShell([
@@ -143,6 +144,11 @@ const routes: Routes = [
component: I4cMrmReportComponent, component: I4cMrmReportComponent,
data: { title: 'I4C MRM Balance Report', breadcrumb: 'MRM Balance Report' } data: { title: 'I4C MRM Balance Report', breadcrumb: 'MRM Balance Report' }
}, },
{
path: 'risk',
component: I4cRiskComponent,
data: { title: 'I4C Risk Registers', breadcrumb: 'Risk Registers' }
},
{ {
path: 'intimation-settings', path: 'intimation-settings',
component: I4cIntimationSettingsComponent, component: I4cIntimationSettingsComponent,
+2 -1
View File
@@ -76,7 +76,8 @@
gap: 10px; gap: 10px;
align-items: center; align-items: center;
mat-form-field { mat-form-field,
mifosx-i4c-date-field {
width: 220px; width: 220px;
} }
} }
+170 -15
View File
@@ -609,7 +609,7 @@
animation: i4c-modal-pop 0.25s cubic-bezier(0.16, 1, 0.3, 1); animation: i4c-modal-pop 0.25s cubic-bezier(0.16, 1, 0.3, 1);
&--wide { &--wide {
max-width: 860px; max-width: min(980px, 95vw);
} }
&--full { &--full {
@@ -679,43 +679,198 @@
} }
} }
/* Modern Modal Tabs Bar */
.i4c-modal-tabs { .i4c-modal-tabs {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 16px; padding: 8px 14px;
background: #f8fafc; background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0;
overflow-x: auto;
flex-shrink: 0; flex-shrink: 0;
.modal-tab { .modal-tab {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 5px; gap: 6px;
padding: 4px 10px; padding: 6px 12px;
border-radius: 6px; border-radius: 8px;
font-size: 11px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #64748b; color: #475569;
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid transparent;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
white-space: nowrap; white-space: nowrap;
user-select: none;
outline: none;
fa-icon {
font-size: 12px;
color: #64748b;
transition:
color 0.2s ease,
transform 0.2s ease;
}
&:hover { &:hover {
color: #0f172a; color: #0f172a;
background: #e2e8f0; background: rgb(226 232 240 / 70%);
fa-icon {
color: #1e293b;
}
} }
&--active { &--active,
color: #2563eb; &--active:hover {
color: #1d4ed8;
background: #fff; background: #fff;
border-color: #e2e8f0; border-color: #cbd5e1;
box-shadow: 0 1px 3px rgb(15 23 42 / 5%); box-shadow:
0 2px 6px -1px rgb(15 23 42 / 8%),
0 1px 3px -1px rgb(15 23 42 / 4%);
fa-icon {
color: #2563eb;
} }
} }
&--has-risk {
&:not(.modal-tab--active) {
color: #991b1b;
background: rgb(254 242 242 / 65%);
border-color: rgb(254 202 202 / 70%);
fa-icon {
color: #dc2626;
}
&:hover {
background: #fee2e2;
color: #7f1d1d;
}
}
&.modal-tab--active {
border-color: #fca5a5;
box-shadow:
0 2px 6px -1px rgb(220 38 38 / 15%),
0 1px 3px -1px rgb(15 23 42 / 4%);
fa-icon {
color: #dc2626;
}
}
}
/* When an existing i4c-badge is nested in a tab */
.i4c-badge {
font-size: 10px;
padding: 1.5px 6.5px;
}
}
}
/* Sleek Tab Badges for Status & Risk Alerts */
.tab-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1.5px 7px;
border-radius: 9999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1.2;
white-space: nowrap;
border: 1px solid transparent;
transition: all 0.2s ease;
&__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentcolor;
display: inline-block;
position: relative;
}
&--pulse &__dot::after {
content: '';
position: absolute;
inset: -2px;
border-radius: 50%;
background: currentcolor;
animation: tab-alert-pulse 1.8s infinite ease-out;
}
&--danger {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
box-shadow: 0 1px 2px rgb(220 38 38 / 6%);
}
&--amber {
background: #fffbeb;
color: #b45309;
border-color: #fde68a;
}
&--green {
background: #ecfdf5;
color: #059669;
border-color: #a7f3d0;
}
&--blue {
background: #eff6ff;
color: #2563eb;
border-color: #bfdbfe;
}
}
/* Compact numeric pill inside tab buttons */
.tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9999px;
font-size: 10px;
font-weight: 700;
background: #e2e8f0;
color: #475569;
line-height: 1;
transition: all 0.2s ease;
}
.modal-tab--active .tab-count {
background: #dbeafe;
color: #1d4ed8;
}
@keyframes tab-alert-pulse {
0% {
transform: scale(0.9);
opacity: 0.9;
}
70% {
transform: scale(2.4);
opacity: 0;
}
100% {
transform: scale(2.4);
opacity: 0;
}
} }
.i4c-modal-body { .i4c-modal-body {
@@ -60,6 +60,103 @@
</div> </div>
} }
<!-- The bank's own IFSC -> I4C Bank Code map -->
<div class="i4c-card">
<div class="i4c-card-head">
<fa-icon icon="exchange-alt"></fa-icon>
<span>IFSC to I4C Bank Code map</span>
<span class="head-hint"
>When money moved to another bank, the response names it by this code so CFCFRMS can send that bank the next
notice (MHA SOP 6(g), 6(j)). A full IFSC wins over a prefix, which wins over the master.</span
>
</div>
<div class="i4c-form ifsc-map-form">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>IFSC prefix or full IFSC</mat-label>
<input matInput [(ngModel)]="mapKey" maxlength="11" placeholder="HDFC or HDFC0CXYZ01" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>I4C Bank Code</mat-label>
<input matInput type="number" [(ngModel)]="mapCode" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="ifsc-map-note">
<mat-label>Note</mat-label>
<input matInput [(ngModel)]="mapNote" maxlength="300" placeholder="e.g. co-operative bank on a sponsor IFSC" />
</mat-form-field>
<button mat-flat-button color="primary" (click)="addMapping()" [disabled]="mapBusy || !mapKey || !mapCode">
Add
</button>
</div>
@if (mapError) {
<div class="i4c-error">
<fa-icon icon="exclamation-circle"></fa-icon>
<span>{{ mapError }}</span>
</div>
}
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>IFSC / prefix</th>
<th class="num">Bank Code</th>
<th>Bank</th>
<th>Note</th>
<th>Added</th>
<th></th>
</tr>
</thead>
<tbody>
@for (m of mappings; track m.id) {
<tr>
<td class="font-mono">{{ m.ifsc_key }}</td>
<td class="num">{{ m.bank_code }}</td>
<td>{{ m.bank_name || '-' }}</td>
<td>{{ m.note || '-' }}</td>
<td>{{ m.added_at | date: 'dd MMM yyyy' }} · {{ m.added_by }}</td>
<td><button mat-button (click)="removeMapping(m.id)" [disabled]="mapBusy">Remove</button></td>
</tr>
} @empty {
<tr>
<td colspan="6" class="i4c-empty">No mappings. The master's IFSC prefixes are used.</td>
</tr>
}
</tbody>
</table>
</div>
@if (unknown.length) {
<div class="i4c-card-head">
<span>Reported with no bank code (payee_bank_code 0)</span>
<span class="head-hint">Map these so later responses name the next bank.</span>
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
<tr>
<th>IFSC</th>
<th class="num">Times</th>
<th>Last seen</th>
<th></th>
</tr>
</thead>
<tbody>
@for (u of unknown; track u.ifsc) {
<tr>
<td class="font-mono">{{ u.ifsc || '(none given)' }}</td>
<td class="num">{{ u.times }}</td>
<td>{{ u.last_seen | date: 'dd MMM yyyy' }}</td>
<td>
@if (u.ifsc) {
<button mat-button (click)="mapKey = u.ifsc">Map</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
<!-- Main Directory Card --> <!-- Main Directory Card -->
<div class="i4c-card"> <div class="i4c-card">
<div class="i4c-card-head"> <div class="i4c-card-head">
@@ -358,3 +358,15 @@
.ifsc-filter { .ifsc-filter {
width: 170px; width: 170px;
} }
.ifsc-map-form {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 12px 16px;
}
.ifsc-map-note {
flex: 1 1 240px;
}
@@ -66,8 +66,61 @@ export class I4cBankMasterComponent implements OnInit {
copiedKey = ''; copiedKey = '';
selectedBankModal: any = null; selectedBankModal: any = null;
mappings: any[] = [];
unknown: any[] = [];
mapKey = '';
mapCode: number | null = null;
mapNote = '';
mapBusy = false;
mapError = '';
ngOnInit(): void { ngOnInit(): void {
this.load(); this.load();
this.loadMap();
}
loadMap(): void {
this.i4c.ifscMap().subscribe({
next: (m) => {
this.mappings = m.mappings;
this.unknown = m.unknown;
},
error: (e) => (this.mapError = i4cErrorMessage(e))
});
}
addMapping(): void {
this.mapBusy = true;
this.mapError = '';
this.i4c
.mapIfsc({ ifscKey: this.mapKey.trim().toUpperCase(), bankCode: Number(this.mapCode), note: this.mapNote.trim() })
.subscribe({
next: () => {
this.mapKey = '';
this.mapCode = null;
this.mapNote = '';
this.mapBusy = false;
this.loadMap();
},
error: (e) => {
this.mapError = i4cErrorMessage(e);
this.mapBusy = false;
}
});
}
removeMapping(id: number): void {
this.mapBusy = true;
this.i4c.unmapIfsc(id).subscribe({
next: () => {
this.mapBusy = false;
this.loadMap();
},
error: (e) => {
this.mapError = i4cErrorMessage(e);
this.mapBusy = false;
}
});
} }
setType(t: string): void { setType(t: string): void {
@@ -195,6 +195,102 @@
</a> </a>
</div> </div>
</section> </section>
<!-- GRM / MRM -->
<section class="kpi-group">
<div class="kpi-group__head">
<fa-icon icon="balance-scale"></fa-icon>
<span>GRM &amp; MRM</span>
</div>
<div class="kpi-group__row">
<a class="modern-kpi" routerLink="../grievances" [class.modern-kpi--warn]="data.grievancesOverdue > 0">
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="balance-scale"></fa-icon></span>
</div>
<span class="modern-kpi__value">{{ data.grievancesOpen }}</span>
<span class="modern-kpi__label">Open Grievances</span>
<span class="modern-kpi__sub"
>{{ data.grievancesOverdue }} past deadline · {{ data.grievancesToCarryOut }} orders to carry out</span
>
</a>
<a class="modern-kpi" routerLink="../restorations" [class.modern-kpi--warn]="data.restorationsOverdue > 0">
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--green"><fa-icon icon="hand-holding-usd"></fa-icon></span>
</div>
<span class="modern-kpi__value">{{ data.restorationsOpen }}</span>
<span class="modern-kpi__label">Open Restorations</span>
<span class="modern-kpi__sub"
>{{ data.restorationsOverdue }} past the 15-day credit · {{ data.restorationsMrmToUpdate }} to enter on
MRM</span
>
</a>
<a class="modern-kpi" routerLink="../restorations" [class.modern-kpi--warn]="data.restorationsInPool > 0">
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--blue"><fa-icon icon="exchange-alt"></fa-icon></span>
</div>
<span class="modern-kpi__value">{{ data.restorationsInPool }}</span>
<span class="modern-kpi__label">Restorations In Transit</span>
<span class="modern-kpi__sub"
>₹{{ data.amountInPool | number: '1.0-0' }} in payable / outward · ₹{{
data.amountRestored | number: '1.0-0'
}}
restored</span
>
</a>
</div>
</section>
<!-- SOP & risk duties -->
<section class="kpi-group">
<div class="kpi-group__head">
<fa-icon icon="user-shield"></fa-icon>
<span>SOP &amp; Risk</span>
</div>
<div class="kpi-group__row">
<a
class="modern-kpi"
routerLink="../holds"
[class.modern-kpi--warn]="data.leaNoticesDue > 0 || data.holdsLapsed > 0"
>
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--amber"><fa-icon icon="calendar-alt"></fa-icon></span>
</div>
<span class="modern-kpi__value">{{ data.leaNoticesDue }}</span>
<span class="modern-kpi__label">LEA Notices Due</span>
<span class="modern-kpi__sub">{{ data.holdsLapsed }} hold(s) lapsed without extension</span>
</a>
<a class="modern-kpi" routerLink="../risk" [class.modern-kpi--warn]="data.mulesSuspected > 0">
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="user-shield"></fa-icon></span>
</div>
<span class="modern-kpi__value">{{ data.mulesSuspected }}</span>
<span class="modern-kpi__label">Suspected Mules</span>
<span class="modern-kpi__sub"
>{{ data.mulesConfirmed }} confirmed · {{ data.digitalSuspended }} digital suspensions</span
>
</a>
<a
class="modern-kpi"
routerLink="../risk"
[class.modern-kpi--warn]="
data.fraudReportsOverdue > 0 || data.linkedAlertsOpen > 0 || data.onwardUnknownBank > 0
"
>
<div class="kpi-top">
<span class="kpi-icon-circle kpi-icon-circle--red"><fa-icon icon="file-alt"></fa-icon></span>
</div>
<span class="modern-kpi__value">{{ data.fraudReportsOverdue }}</span>
<span class="modern-kpi__label">FMR / STR Overdue</span>
<span class="modern-kpi__sub"
>{{ data.linkedAlertsOpen }} linked alerts · {{ data.onwardUnknownBank || 0 }} onward transfers with no
bank code · {{ data.shadowCreditsDue }} shadow credits due · {{ data.bankHoldsOpen }} bank holds ({{
data.bankHoldDecisionsDue
}}
decisions due)</span
>
</a>
</div>
</section>
</div> </div>
<!-- Middle Row: API Performance + Complaints Status --> <!-- Middle Row: API Performance + Complaints Status -->
@@ -0,0 +1,22 @@
<!--
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/.
-->
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ label }}</mat-label>
<input
matInput
[matDatepicker]="picker"
[value]="date"
[max]="maxDate"
(dateChange)="onChange($event)"
placeholder="dd/mm/yyyy"
autocomplete="off"
/>
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
@@ -0,0 +1,17 @@
/**
* 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/.
*/
// Fills the width the parent form gives it; size tokens (field height, text size) are inherited from the parent.
:host {
display: block;
min-width: 0;
}
mat-form-field {
width: 100%;
}
@@ -0,0 +1,67 @@
/**
* 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 { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core';
import { MatDatepickerInputEvent, MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { DdsDateAdapter, DDS_DATE_FORMATS } from '../../../organization/dds-batch-processing/dds-date-adapter';
/**
* A date field with a calendar picker for the I4C forms. Shown and typed day-first (dd/mm/yyyy); the value in and out is
* the yyyy-MM-dd string the I4C APIs take, or '' when empty, so it drops in where a plain date input bound a string.
*/
@Component({
selector: 'mifosx-i4c-date-field',
templateUrl: './i4c-date-field.component.html',
styleUrls: ['./i4c-date-field.component.scss'],
imports: [
MatFormFieldModule,
MatInputModule,
MatDatepickerModule
],
providers: [
{ provide: DateAdapter, useClass: DdsDateAdapter },
{ provide: MAT_DATE_FORMATS, useValue: DDS_DATE_FORMATS }
]
})
export class I4cDateFieldComponent {
@Input() label = '';
/** Latest date that can be picked, yyyy-MM-dd; defaults to none. */
@Input() max = '';
@Output() valueChange = new EventEmitter<string>();
date: Date | null = null;
/** yyyy-MM-dd, or '' for no date. */
@Input()
set value(value: string | null | undefined) {
this.date = parse(value);
}
get maxDate(): Date | null {
return parse(this.max);
}
onChange(event: MatDatepickerInputEvent<Date>): void {
this.date = event.value;
this.valueChange.emit(event.value ? iso(event.value) : '');
}
}
function parse(value: string | null | undefined): Date | null {
const match = value ? /^(\d{4})-(\d{2})-(\d{2})/.exec(value) : null;
return match ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : null;
}
/** yyyy-MM-dd in the browser's own time zone (toISOString would shift to UTC). */
function iso(d: Date): string {
const pad = (n: number) => (n < 10 ? '0' + n : '' + n);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
@@ -9,75 +9,144 @@
<div class="docs"> <div class="docs">
@if (missing.length) { @if (missing.length) {
<div class="docs__missing"> <div class="docs__missing">
<div class="docs__missing-head">
<fa-icon icon="exclamation-triangle"></fa-icon> <fa-icon icon="exclamation-triangle"></fa-icon>
Still needed: <span>Mandatory Documents Required (MHA SOP):</span>
@for (m of missing; track m; let last = $last) { </div>
<b>{{ label(m) }}</b <div class="docs__missing-chips">
>{{ last ? '' : ', ' }} @for (m of missing; track m) {
<span class="missing-chip">{{ label(m) }}</span>
} }
</div> </div>
</div>
} }
<table class="i4c-table"> <div class="docs__table-wrap">
<table class="i4c-table docs-table">
<thead> <thead>
<tr> <tr>
<th>Type</th> <th>Document Type</th>
<th>File</th> <th>File Details</th>
<th>Remarks</th> <th>Remarks</th>
<th>Uploaded</th> <th>Uploaded Info</th>
<th></th> <th class="action-col"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (d of rows; track d.id) { @for (d of rows; track d.id) {
<tr> <tr>
<td>{{ label(d.doc_type) }}</td>
<td> <td>
<span class="doc-type-badge">{{ label(d.doc_type) }}</span>
</td>
<td>
<div class="doc-file-info">
<span class="docs__name">{{ d.file_name }}</span> <span class="docs__name">{{ d.file_name }}</span>
<div class="docs__sub" [title]="'SHA-256 ' + d.sha256"> <div class="docs__sub" [title]="'SHA-256: ' + d.sha256">
{{ size(d.size_bytes) }} · {{ d.sha256.slice(0, 12) }}… <span class="doc-size">{{ size(d.size_bytes) }}</span>
<span class="doc-hash-pill"><fa-icon icon="shield-alt"></fa-icon> {{ d.sha256.slice(0, 10) }}…</span>
</div>
</div> </div>
</td> </td>
<td>{{ d.remarks || '-' }}</td>
<td> <td>
{{ d.uploaded_at | date: 'dd MMM yyyy, HH:mm' }} <span class="doc-remarks">{{ d.remarks || '-' }}</span>
<div class="docs__sub">{{ d.uploaded_by }}</div>
</td> </td>
<td> <td>
<button mat-stroked-button type="button" (click)="download(d)"> <div class="doc-upload-meta">
<span class="doc-upload-date">{{ d.uploaded_at | date: 'dd MMM yyyy, HH:mm' }}</span>
<span class="doc-upload-by">by {{ d.uploaded_by }}</span>
</div>
</td>
<td class="action-col">
<button class="doc-download-btn" type="button" (click)="download(d)">
<fa-icon icon="download"></fa-icon> Download <fa-icon icon="download"></fa-icon> Download
</button> </button>
</td> </td>
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="5" class="i4c-empty">No documents attached yet.</td> <td colspan="5" class="docs-empty-row">
<div class="docs-empty-state">
<div class="docs-empty-state__icon">
<fa-icon icon="folder-open"></fa-icon>
</div>
<div class="docs-empty-state__title">No documents attached yet</div>
<div class="docs-empty-state__desc">
Upload police intimations, IO notices, court orders (freeze/unfreeze), or grievance evidence below.
</div>
</div>
</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div>
@if (canUpload) { @if (canUpload) {
<div class="docs__upload"> <div class="docs__upload-card">
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <div class="docs__upload-head">
<mat-label>Document type</mat-label> <div class="docs__upload-title">
<fa-icon icon="cloud-upload-alt"></fa-icon>
<span>Attach Case Document</span>
</div>
<span class="docs__upload-badge">Secure Vault</span>
</div>
<div class="docs__upload-body">
<div class="docs__upload-row">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="docs__type-field">
<mat-label>Document Type</mat-label>
<mat-select [(ngModel)]="docType"> <mat-select [(ngModel)]="docType">
@for (t of types; track t.value) { @for (t of types; track t.value) {
<mat-option [value]="t.value">{{ t.label }}</mat-option> <mat-option [value]="t.value">{{ t.label }}</mat-option>
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="docs__remarks">
<mat-label>Remarks (order no., date…)</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic" class="docs__remarks-field">
<input matInput [(ngModel)]="remarks" maxlength="500" /> <mat-label>Remarks / Reference (Order no., Court, Date)</mat-label>
<input matInput [(ngModel)]="remarks" maxlength="500" placeholder="e.g. IO Order Ref #1234, FIR #56/26" />
</mat-form-field> </mat-form-field>
<label class="docs__file"> </div>
<input #fileInput type="file" accept="application/pdf,image/jpeg,image/png" (change)="pick($event)" />
</label> <div class="docs__upload-action-row">
<button mat-flat-button color="primary" type="button" (click)="upload(fileInput)" [disabled]="uploading || !file"> <input
<fa-icon icon="upload"></fa-icon> {{ uploading ? 'Uploading…' : 'Attach' }} #fileInput
type="file"
accept="application/pdf,image/jpeg,image/png"
(change)="pick($event)"
class="hidden-file-input"
/>
@if (!file) {
<button type="button" class="file-picker-trigger" (click)="fileInput.click()">
<fa-icon icon="paperclip"></fa-icon>
<span>Choose File (PDF, JPEG, PNG)</span>
</button> </button>
<span class="docs__sub">PDF, JPEG or PNG, up to 5 MB. Stored with a SHA-256; never changed once attached.</span> } @else {
<div class="file-selected-chip">
<fa-icon icon="file-alt"></fa-icon>
<span class="file-selected-name" [title]="file.name">{{ file.name }}</span>
<span class="file-selected-size">({{ size(file.size) }})</span>
<button type="button" class="file-remove-btn" (click)="clearFile(fileInput)" title="Remove file">
<fa-icon icon="times"></fa-icon>
</button>
</div>
}
<button class="doc-submit-btn" type="button" (click)="upload(fileInput)" [disabled]="uploading || !file">
<fa-icon icon="upload"></fa-icon>
<span>{{ uploading ? 'Uploading…' : 'Attach Document' }}</span>
</button>
</div>
<div class="docs__security-footer">
<fa-icon icon="shield-alt"></fa-icon>
<span
>Maximum size 5 MB. Files are stored immutably with SHA-256 cryptographic hashing for regulatory
audit.</span
>
</div>
</div>
</div> </div>
} }
</div> </div>
@@ -17,50 +17,420 @@
.docs { .docs {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 12px;
/* Alert banner for required documents */
&__missing { &__missing {
padding: 8px 12px; padding: 9px 12px;
border-radius: 8px; border-radius: 8px;
border: 1px solid #fde68a; border: 1px solid #fde68a;
background: #fffbeb; background: #fffbeb;
color: #92400e; color: #92400e;
font-size: 12.5px; display: flex;
flex-direction: column;
gap: 6px;
&-head {
display: flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
font-weight: 700;
fa-icon {
color: #d97706;
}
}
&-chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 5px;
}
.missing-chip {
padding: 2px 7px;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 4px;
font-size: 10.5px;
font-weight: 600;
color: #78350f;
}
}
/* Modern Documents Table */
&__table-wrap {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.docs-table {
width: 100%;
margin: 0;
border: none;
thead th {
background: #f8fafc;
color: #475569;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 8px 12px;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
tbody td {
padding: 9px 12px;
border-bottom: 1px solid #f1f5f9;
font-size: 11.5px;
vertical-align: middle;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover:not(.docs-empty-row) {
background: #f8fafc;
}
.action-col {
text-align: right;
width: 110px;
}
}
.doc-type-badge {
display: inline-flex;
align-items: center; align-items: center;
padding: 2px 7px;
border-radius: 4px;
background: #eff6ff;
color: #2563eb;
border: 1px solid #bfdbfe;
font-size: 10px;
font-weight: 700;
white-space: nowrap;
}
.doc-file-info {
display: flex;
flex-direction: column;
gap: 2px;
} }
&__name { &__name {
font-weight: 600; font-weight: 600;
color: #0f172a;
font-size: 11.5px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
&__sub { &__sub {
font-size: 10.5px;
color: #64748b;
display: inline-flex;
align-items: center;
gap: 6px;
}
.doc-size {
font-weight: 500;
}
.doc-hash-pill {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 4px;
background: #f1f5f9;
border-radius: 4px;
font-family: monospace;
font-size: 10px;
color: #475569;
fa-icon {
font-size: 9px;
color: #64748b;
}
}
.doc-remarks {
color: #334155;
font-size: 11px; font-size: 11px;
max-width: 220px;
overflow-wrap: anywhere;
}
.doc-upload-meta {
display: flex;
flex-direction: column;
gap: 1px;
}
.doc-upload-date {
color: #0f172a;
font-weight: 600;
font-size: 11px;
}
.doc-upload-by {
color: #64748b;
font-size: 10.5px;
}
.doc-download-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 26px;
padding: 0 9px;
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: #eff6ff;
border-color: #93c5fd;
color: #1d4ed8;
}
}
/* Clean Empty State */
.docs-empty-state {
text-align: center;
padding: 22px 14px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&__icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: #f1f5f9;
color: #94a3b8;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-bottom: 8px;
}
&__title {
font-size: 12px;
font-weight: 700;
color: #334155;
}
&__desc {
font-size: 11px;
color: #64748b;
max-width: 420px;
margin-top: 3px;
line-height: 1.4;
}
}
/* Modern Upload Card */
&__upload-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
overflow: hidden;
}
&__upload-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
}
&__upload-title {
font-size: 11.5px;
font-weight: 700;
color: #0f172a;
display: flex;
align-items: center;
gap: 6px;
fa-icon {
color: #2563eb;
font-size: 12px;
}
}
&__upload-badge {
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
background: #eff6ff;
color: #2563eb;
padding: 1px 6px;
border-radius: 9999px;
}
&__upload-body {
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
&__upload-row {
display: grid;
grid-template-columns: 200px 1fr;
gap: 8px;
@media (width <= 640px) {
grid-template-columns: 1fr;
}
}
&__upload-action-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.hidden-file-input {
display: none;
}
/* Custom Styled File Picker Button */
.file-picker-trigger {
height: 32px;
padding: 0 12px;
border-radius: 6px;
border: 1px dashed #cbd5e1;
background: #fff;
color: #334155;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
fa-icon {
color: #64748b;
font-size: 12px;
}
&:hover {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
fa-icon {
color: #2563eb;
}
}
}
.file-selected-chip {
height: 32px;
padding: 0 8px 0 10px;
border-radius: 6px;
border: 1px solid #bfdbfe;
background: #eff6ff;
color: #1e40af;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
fa-icon {
color: #2563eb;
}
}
.file-selected-name {
font-weight: 600;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-selected-size {
font-size: 10.5px;
color: #64748b; color: #64748b;
} }
&__upload { .file-remove-btn {
display: flex; border: none;
flex-wrap: wrap; background: transparent;
color: #dc2626;
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
display: inline-flex;
align-items: center; align-items: center;
gap: 10px; justify-content: center;
padding-top: 10px; transition: background 0.15s ease;
border-top: 1px solid #f1f5f9;
mat-form-field { &:hover {
width: 200px; background: rgb(220 38 38 / 12%);
} }
} }
&__remarks { .doc-submit-btn {
flex: 1 1 220px; height: 32px;
padding: 0 14px;
border-radius: 6px;
background: #2563eb;
color: #fff;
font-size: 11.5px;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
box-shadow: 0 1px 2px rgb(37 99 235 / 20%);
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
&__file input { &:hover:not(:disabled) {
font-size: 12px; background: #1d4ed8;
max-width: 240px; }
}
&__security-footer {
display: flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
color: #64748b;
margin-top: 2px;
fa-icon {
color: #059669;
font-size: 10px;
}
} }
} }
@@ -116,6 +116,11 @@ export class I4cDocumentsComponent implements OnChanges {
this.file = f; this.file = f;
} }
clearFile(input: HTMLInputElement): void {
this.file = null;
input.value = '';
}
upload(input: HTMLInputElement): void { upload(input: HTMLInputElement): void {
if (!this.file || !this.docType) { if (!this.file || !this.docType) {
this.snackBar.open('Choose the document type and the file.', 'Close', { duration: 4000 }); this.snackBar.open('Choose the document type and the file.', 'Close', { duration: 4000 });
@@ -327,10 +327,10 @@
<input matInput [(ngModel)]="decision.orderRef" maxlength="200" /> <input matInput [(ngModel)]="decision.orderRef" maxlength="200" />
</mat-form-field> </mat-form-field>
} }
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mifosx-i4c-date-field
<mat-label>Order / decision date</mat-label> label="Order / decision date"
<input matInput type="date" [(ngModel)]="decision.orderDate" /> [(value)]="decision.orderDate"
</mat-form-field> ></mifosx-i4c-date-field>
</div> </div>
<mat-form-field appearance="outline" class="step__wide"> <mat-form-field appearance="outline" class="step__wide">
<mat-label <mat-label
@@ -379,6 +379,18 @@
</div> </div>
} }
@if (ioSilent(g)) {
<div class="step">
<div class="step__title err-text">The IO has not decided within 15 days</div>
<div class="sub">SOP 9.1: the grievance goes to the District Grievance Officer.</div>
<div class="i4c-actions">
<button mat-flat-button color="warn" (click)="escalate()" [disabled]="busy">
<fa-icon icon="arrow-up"></fa-icon> Escalate to District GRO
</button>
</div>
</div>
}
@if (g.status === 'REJECTED' && g.stage !== 'CLOSED') { @if (g.status === 'REJECTED' && g.stage !== 'CLOSED') {
<div class="step"> <div class="step">
<div class="step__title">Rejected by the {{ g.decision_level }}: {{ g.decision_remarks }}</div> <div class="step__title">Rejected by the {{ g.decision_level }}: {{ g.decision_remarks }}</div>
@@ -11,3 +11,7 @@
@include i4c-skin; @include i4c-skin;
@include i4c-case-page; @include i4c-case-page;
.err-text {
color: #dc2626;
}
@@ -20,6 +20,7 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { I4cService, i4cErrorMessage } from '../i4c.service'; import { I4cService, i4cErrorMessage } from '../i4c.service';
import { I4cDateFieldComponent } from '../i4c-date-field/i4c-date-field.component';
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component'; import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component'; import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
@@ -38,6 +39,7 @@ export const GRM_DECISIONS: { value: string; label: string }[] = [
{ value: 'UNFREEZE_HOLD_CONTINUES', label: 'Unfreeze the account, hold continues' }, { value: 'UNFREEZE_HOLD_CONTINUES', label: 'Unfreeze the account, hold continues' },
{ value: 'UNFREEZE_AND_REMOVE_HOLD', label: 'Unfreeze and remove put on hold' }, { value: 'UNFREEZE_AND_REMOVE_HOLD', label: 'Unfreeze and remove put on hold' },
{ value: 'PARTIAL_HOLD', label: 'Keep a smaller amount on hold' }, { value: 'PARTIAL_HOLD', label: 'Keep a smaller amount on hold' },
{ value: 'RESTORE_DIGITAL', label: 'Restore digital services' },
{ value: 'REJECTED_CONTINUE', label: 'Request rejected, hold / freeze continues' }, { value: 'REJECTED_CONTINUE', label: 'Request rejected, hold / freeze continues' },
{ value: 'SEEK_INFO', label: 'Seek additional information' } { value: 'SEEK_INFO', label: 'Seek additional information' }
]; ];
@@ -69,7 +71,8 @@ const REQUESTS: Record<string, string> = {
MatPaginatorModule, MatPaginatorModule,
FaIconComponent, FaIconComponent,
I4cDateRangeComponent, I4cDateRangeComponent,
I4cDocumentsComponent I4cDocumentsComponent,
I4cDateFieldComponent
] ]
}) })
export class I4cGrievancesComponent implements OnInit { export class I4cGrievancesComponent implements OnInit {
@@ -344,6 +347,11 @@ export class I4cGrievancesComponent implements OnInit {
}); });
} }
/** SOP 9.1: the IO did not decide within 15 days. */
ioSilent(g: any): boolean {
return g.status === 'OPEN' && (g.stage === 'WITH_IO' || g.stage === 'IO_INVESTIGATION') && this.isLate(g.due_at);
}
missingDocs(g: any): string[] { missingDocs(g: any): string[] {
return (g.requiredDocuments || []).filter((t: string) => !(g.documents || []).some((d: any) => d.doc_type === t)); return (g.requiredDocuments || []).filter((t: string) => !(g.documents || []).some((d: any) => d.doc_type === t));
} }
File diff suppressed because it is too large Load Diff
@@ -200,37 +200,809 @@
max-width: 320px; max-width: 320px;
} }
.cases-grid { /* ========================================================
display: grid; SHARED PRIMITIVES FOR I4C CASES & RISK TABS
grid-template-columns: repeat(2, minmax(0, 1fr)); ======================================================== */
gap: 14px; .card-icon-badge {
} width: 24px;
height: 24px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
flex-shrink: 0;
@media (width <= 900px) { &--blue {
.cases-grid { background: #eff6ff;
grid-template-columns: minmax(0, 1fr); color: #2563eb;
}
&--amber {
background: #fffbeb;
color: #d97706;
}
&--red {
background: #fef2f2;
color: #dc2626;
}
&--purple {
background: #f5f3ff;
color: #7c3aed;
}
&--green {
background: #ecfdf5;
color: #059669;
} }
} }
.case-row { .status-chip {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: 9999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.02em;
&--green {
background: #ecfdf5;
color: #059669;
}
&--blue {
background: #eff6ff;
color: #2563eb;
}
&--neutral {
background: #e2e8f0;
color: #475569;
}
}
.risk-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 30px;
padding: 0 12px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
align-self: flex-start;
user-select: none;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--primary {
background: #2563eb;
color: #fff;
box-shadow: 0 1px 2px rgb(37 99 235 / 20%);
&:hover:not(:disabled) {
background: #1d4ed8;
}
}
&--danger {
background: #dc2626;
color: #fff;
box-shadow: 0 1px 2px rgb(220 38 38 / 20%);
&:hover:not(:disabled) {
background: #b91c1c;
}
}
&--warn {
background: #d97706;
color: #fff;
box-shadow: 0 1px 2px rgb(217 119 6 / 20%);
&:hover:not(:disabled) {
background: #b45309;
}
}
&--success {
background: #059669;
color: #fff;
box-shadow: 0 1px 2px rgb(5 150 105 / 20%);
&:hover:not(:disabled) {
background: #047857;
}
}
&--ghost {
background: #f1f5f9;
color: #475569;
border-color: #e2e8f0;
&:hover:not(:disabled) {
background: #e2e8f0;
color: #1e293b;
}
}
}
.risk-btn-group {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap;
}
/* ========================================================
GRM / MRM & Documents Tab: Modern Executive Case Hub
======================================================== */
.cases-tab {
display: flex;
flex-direction: column;
gap: 12px;
.cases-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@media (width <= 900px) {
grid-template-columns: minmax(0, 1fr);
}
}
.case-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
box-shadow:
0 1px 3px rgb(15 23 42 / 4%),
0 1px 2px rgb(15 23 42 / 2%);
overflow: hidden;
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
border-color: #cbd5e1;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 12px;
background: linear-gradient(180deg, #fff 0%, #f8fafc 100%);
border-bottom: 1px solid #e2e8f0;
}
&__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
font-weight: 700;
color: #0f172a;
}
&__subtitle {
font-size: 10.5px;
font-weight: normal;
color: #64748b;
margin-top: 1px;
}
&__body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
font-size: 11.5px;
}
}
/* Interactive Case List Items */
.case-item-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px; padding: 8px 10px;
margin-bottom: 8px; background: #f8fafc;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease;
&:hover, &:hover,
&:focus-visible { &:focus-visible {
border-color: #2563eb; border-color: #2563eb;
background: #eff6ff;
outline: none; outline: none;
transform: translateY(-1px);
box-shadow: 0 2px 6px rgb(37 99 235 / 8%);
.case-view-link {
color: #1d4ed8;
}
}
&__top {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
b {
font-size: 12px;
color: #0f172a;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 10.5px;
color: #64748b;
}
}
.case-badge-pill {
padding: 1px 6px;
border-radius: 4px;
background: #e2e8f0;
color: #475569;
font-size: 10px;
font-weight: 600;
}
.restorable-amt-pill {
padding: 1px 7px;
border-radius: 9999px;
background: #ecfdf5;
color: #059669;
border: 1px solid #a7f3d0;
font-size: 11px;
font-weight: 700;
margin-left: auto;
}
.case-view-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: #2563eb;
font-weight: 600;
font-size: 10.5px;
fa-icon {
font-size: 9px;
}
}
/* Action Panel inside Case Cards */
.case-action-panel {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
&__head {
font-size: 10.5px;
font-weight: 700;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.04em;
display: flex;
align-items: center;
gap: 5px;
fa-icon {
color: #2563eb;
font-size: 11px;
}
}
}
.case-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
.full-span {
grid-column: 1 / -1;
}
@media (width <= 480px) {
grid-template-columns: 1fr;
}
}
.case-empty-state {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
background: #f8fafc;
border: 1px dashed #cbd5e1;
color: #64748b;
font-size: 11px;
line-height: 1.4;
fa-icon {
color: #94a3b8;
font-size: 13px;
flex-shrink: 0;
}
}
/* Statement Tool Card */
.statement-card {
.statement-form-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
mat-form-field,
mifosx-i4c-date-field {
width: 190px;
flex: 1 1 180px;
}
.statement-btn {
height: 34px;
align-self: center;
white-space: nowrap;
}
}
}
mat-form-field,
mifosx-i4c-date-field {
width: 100%;
--mat-form-field-container-height: 34px;
--mat-form-field-container-vertical-padding: 6px;
--mat-form-field-container-text-size: 11.5px;
--mat-form-field-outlined-label-text-size: 11.5px;
--mat-select-trigger-text-size: 11.5px;
--mat-form-field-outlined-container-shape: 6px;
}
}
/* SOP & Risk tab: balanced 2-column executive dashboard layout */
.risk-tab {
display: flex;
flex-direction: column;
gap: 12px;
/* Sleek Executive Risk Notification Banner */
.risk-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: linear-gradient(135deg, #fff7f7 0%, #fef2f2 100%);
border: 1px solid #fecaca;
border-left: 4px solid #ef4444;
border-radius: 10px;
box-shadow: 0 1px 4px rgb(239 68 68 / 6%);
&__icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: #fee2e2;
color: #dc2626;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
flex-shrink: 0;
}
&__content {
flex: 1;
min-width: 0;
}
&__head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 2px;
}
&__title {
font-size: 12.5px;
font-weight: 700;
color: #991b1b;
}
&__tag {
display: inline-flex;
align-items: center;
padding: 1px 7px;
border-radius: 9999px;
background: #fee2e2;
color: #991b1b;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
&__desc {
font-size: 11px;
color: #7f1d1d;
line-height: 1.45;
b {
font-weight: 700;
color: #450a0a;
}
}
}
/* 2-Column Responsive Dashboard Grid (Columns don't stretch each other) */
.risk-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: start;
@media (width <= 960px) {
grid-template-columns: 1fr;
}
}
.risk-col {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Executive Risk Cards */
.risk-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 10px;
box-shadow:
0 1px 3px rgb(15 23 42 / 4%),
0 1px 2px rgb(15 23 42 / 2%);
overflow: hidden;
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
&:hover {
box-shadow: 0 4px 12px rgb(15 23 42 / 6%);
border-color: #cbd5e1;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 9px 12px;
background: linear-gradient(180deg, #fff 0%, #f8fafc 100%);
border-bottom: 1px solid #e2e8f0;
}
&__title {
display: flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-weight: 700;
color: #0f172a;
}
&__body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
font-size: 11.5px;
}
}
/* Key-Value Timeline Rows */
.risk-kv-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.risk-kv-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 8px;
border-radius: 6px;
background: #f8fafc;
font-size: 11.5px;
&__label {
color: #64748b;
font-weight: 500;
}
&__value {
color: #0f172a;
font-weight: 600;
text-align: right;
display: inline-flex;
align-items: center;
gap: 5px;
}
}
/* Compact Action Boxes */
.risk-action-box {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
&__head {
font-size: 10.5px;
font-weight: 700;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.04em;
display: flex;
align-items: center;
gap: 5px;
fa-icon {
color: #64748b;
font-size: 10px;
}
}
}
.risk-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
@media (width <= 480px) {
grid-template-columns: 1fr;
}
}
.risk-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 8px;
}
/* Modern Material Form Field Overrides */
mat-form-field,
mifosx-i4c-date-field {
width: 100%;
--mat-form-field-container-height: 34px;
--mat-form-field-container-vertical-padding: 6px;
--mat-form-field-container-text-size: 11.5px;
--mat-form-field-outlined-label-text-size: 11.5px;
--mat-select-trigger-text-size: 11.5px;
--mat-form-field-outlined-container-shape: 6px;
}
/* Complaint info chip */
.complaint-chip-row {
margin-bottom: 2px;
}
.complaint-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: #fffbeb;
color: #92400e;
border: 1px solid #fde68a;
border-radius: 6px;
font-size: 11px;
fa-icon {
color: #d97706;
}
}
/* List Items inside Cards */
.case-card-item {
display: flex;
flex-direction: column;
gap: 3px;
padding: 7px 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 11px;
&__top {
display: flex;
align-items: center;
gap: 6px;
}
&__source {
font-weight: 700;
color: #1e293b;
}
&__date {
margin-left: auto;
color: #94a3b8;
font-size: 10px;
}
&__reason {
color: #475569;
}
&__remarks {
color: #64748b;
font-style: italic;
}
&__val {
margin-left: auto;
font-weight: 700;
color: #0f172a;
}
}
/* Transfer Alert Card */
.transfer-alert-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
&__top {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
}
.transfer-type-tag {
padding: 1px 6px;
border-radius: 4px;
background: #eff6ff;
color: #2563eb;
font-size: 10px;
font-weight: 700;
}
.transfer-acc {
font-size: 11.5px;
color: #0f172a;
}
.transfer-amount {
font-weight: 700;
color: #b91c1c;
font-size: 12px;
margin-left: auto;
}
&__meta {
font-size: 10.5px;
color: #64748b;
}
}
/* Channel status box */
.channel-status-box {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
color: #166534;
font-size: 11px;
fa-icon {
color: #16a34a;
margin-top: 2px;
}
}
/* Empty Prompt */
.empty-prompt {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 8px;
&__text {
font-size: 11px;
color: #64748b;
line-height: 1.45;
b {
color: #334155;
}
}
}
.compliance-note {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 6px;
font-size: 11px;
color: #1e40af;
line-height: 1.4;
fa-icon {
color: #3b82f6;
margin-top: 1px;
}
}
.sub-timeline {
display: flex;
align-items: center;
gap: 10px;
font-size: 11px;
color: #475569;
flex-wrap: wrap;
}
.sub-clean {
display: flex;
align-items: center;
gap: 6px;
color: #059669;
font-size: 11px;
font-weight: 500;
} }
.sub { .sub {
font-size: 11px; font-size: 11px;
color: #64748b; color: #64748b;
line-height: 1.45;
} }
} }
@@ -7,7 +7,7 @@
*/ */
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { DatePipe, DecimalPipe } from '@angular/common'; import { DatePipe, DecimalPipe, LowerCasePipe, TitleCasePipe } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@@ -20,6 +20,7 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { HOLD_REASON_TYPES, HoldActionRequest, I4cHold, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service'; import { HOLD_REASON_TYPES, HoldActionRequest, I4cHold, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service';
import { I4cDateFieldComponent } from '../i4c-date-field/i4c-date-field.component';
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component'; import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
import { MRM_BASES } from '../i4c-restorations/i4c-restorations.component'; import { MRM_BASES } from '../i4c-restorations/i4c-restorations.component';
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component'; import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
@@ -36,6 +37,8 @@ import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-rang
imports: [ imports: [
DatePipe, DatePipe,
DecimalPipe, DecimalPipe,
LowerCasePipe,
TitleCasePipe,
FormsModule, FormsModule,
RouterLink, RouterLink,
MatButtonModule, MatButtonModule,
@@ -47,7 +50,8 @@ import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-rang
MatPaginatorModule, MatPaginatorModule,
I4cDateRangeComponent, I4cDateRangeComponent,
I4cDocumentsComponent, I4cDocumentsComponent,
FaIconComponent FaIconComponent,
I4cDateFieldComponent
] ]
}) })
export class I4cHoldsComponent implements OnInit { export class I4cHoldsComponent implements OnInit {
@@ -96,7 +100,14 @@ export class I4cHoldsComponent implements OnInit {
loading = false; loading = false;
busy = false; busy = false;
error = ''; error = '';
modalTab: 'overview' | 'txns' | 'intimation' | 'action' | 'cases' | 'history' = 'overview'; modalTab: 'overview' | 'txns' | 'intimation' | 'action' | 'cases' | 'risk' | 'history' = 'overview';
leaNotice = { sentOn: '', reference: '' };
extension = { until: '', reference: '' };
muleForm = { source: 'OFFICER', reason: '' };
muleReview = { outcome: 'CONFIRMED', remarks: '' };
fraudForm: Record<string, any> = {};
alertRemarks: Record<number, string> = {};
showCause = { sentOn: '', respondedOn: '', response: '', orderRef: '', orderOn: '' };
readonly mrmBases = MRM_BASES; readonly mrmBases = MRM_BASES;
grievanceForm = { customerRequest: 'REMOVE_HOLD', customerReason: '' }; grievanceForm = { customerRequest: 'REMOVE_HOLD', customerReason: '' };
assessForm = { basis: 'ATTRIBUTION', restorableAmount: null as number | null, remarks: '' }; assessForm = { basis: 'ATTRIBUTION', restorableAmount: null as number | null, remarks: '' };
@@ -228,16 +239,19 @@ export class I4cHoldsComponent implements OnInit {
return []; return [];
} }
const list = [{ value: 'RELEASE', label: 'Release hold' }]; const list = [{ value: 'RELEASE', label: 'Release hold' }];
if (h.account_type === 'SAVINGS') {
list.push( list.push(
h.debit_frozen h.debit_frozen
? { value: 'UNFREEZE', label: 'Unfreeze debits, hold continues' } ? { value: 'UNFREEZE', label: 'Unfreeze debits, hold continues' }
: { value: 'FREEZE', label: 'Freeze all debits (on explicit LEA / court direction only)' } : { value: 'FREEZE', label: 'Freeze all debits (on explicit LEA / court direction only)' }
); );
}
if (h.debit_frozen) { if (h.debit_frozen) {
list.push({ value: 'UNFREEZE_AND_RELEASE', label: 'Unfreeze and remove the hold' }); list.push({ value: 'UNFREEZE_AND_RELEASE', label: 'Unfreeze and remove the hold' });
} }
list.push(
h.digital_suspended
? { value: 'RESTORE_DIGITAL', label: 'Restore digital banking (UPI, IMPS, NEFT, RTGS, ATM, cards)' }
: { value: 'SUSPEND_DIGITAL', label: 'Suspend digital banking (repeatedly reported account)' }
);
return list; return list;
} }
@@ -307,6 +321,90 @@ export class I4cHoldsComponent implements OnInit {
}); });
} }
/** POST on /i4c/cases and reload the hold. */
private caseAction(path: string, body: Record<string, any>, done: string, put = false): void {
if (!this.selected) {
return;
}
const id = this.selected.id;
this.busy = true;
(put ? this.i4c.casePut(path, body) : this.i4c.casePost(path, body)).subscribe({
next: () => {
this.busy = false;
this.snackBar.open(done, 'OK', { duration: 4000 });
this.select(id);
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
}
});
}
recordLeaNotice(): void {
this.caseAction('holds/' + this.selected!.id + '/lea-notice', this.leaNotice, 'Notice to the LEA recorded.');
}
recordExtension(): void {
this.caseAction('holds/' + this.selected!.id + '/extension', this.extension, 'Extension recorded.');
}
flagMule(): void {
this.caseAction('holds/' + this.selected!.id + '/mule-flag', this.muleForm, 'Account added to the mule register.');
}
reviewMule(flagId: number): void {
this.caseAction('mule-flags/' + flagId + '/review', this.muleReview, 'Review recorded.');
}
fraudStep(reportId: number, step: string, body: Record<string, any>): void {
this.caseAction('fraud-reports/' + reportId + '/' + step, body, 'Recorded.');
}
openFraudReport(): void {
this.caseAction(
'holds/' + this.selected!.id + '/fraud-report',
{},
'Fraud case opened: STR due in 7 days; send the show-cause notice next.'
);
}
editFraudReport(r: any): void {
this.fraudForm = {
id: r.id,
policeRef: r.police_ref || '',
policeReportedOn: r.police_reported_on || '',
fmrRef: r.fmr_ref || '',
fmrFiledOn: r.fmr_filed_on || '',
strRef: r.str_ref || '',
strFiledOn: r.str_filed_on || '',
remarks: r.remarks || ''
};
}
saveFraudReport(): void {
const { id, ...body } = this.fraudForm;
this.caseAction('fraud-reports/' + id, body, 'Fraud reporting updated.', true);
this.fraudForm = {};
}
actionAlert(alertId: number, outcome: 'ACTIONED' | 'DISMISSED'): void {
this.caseAction(
'linked-alerts/' + alertId,
{ outcome, remarks: this.alertRemarks[alertId] || '' },
'Alert ' + outcome.toLowerCase() + '.'
);
}
/** Whether fraud money from this account repaid a loan here (MHA SOP 9.1(v)). */
repaidLoan(hold: I4cHold): boolean {
return (hold.linkedAlerts || []).some((a) => a.kind === 'LOAN_REPAYMENT');
}
isPast(date: string | null | undefined): boolean {
return !!date && new Date(date).getTime() < Date.now();
}
goTo(page: 'grievances' | 'restorations', id: number): void { goTo(page: 'grievances' | 'restorations', id: number): void {
this.router.navigate(['/cfcfrms/i4c/' + page], { queryParams: { open: id } }); this.router.navigate(['/cfcfrms/i4c/' + page], { queryParams: { open: id } });
} }
@@ -75,6 +75,16 @@
<mat-option value="RESPONSE">RESPONSE only</mat-option> <mat-option value="RESPONSE">RESPONSE only</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Layer</mat-label>
<mat-select [(ngModel)]="outboxLayer" (selectionChange)="loadOutbox(true)">
<mat-option value="">All layers</mat-option>
<mat-option value="0">L0 · Victim</mat-option>
<mat-option value="1">L1 · First layer</mat-option>
<mat-option value="2">L2</mat-option>
<mat-option value="3">L3</mat-option>
</mat-select>
</mat-form-field>
<mifosx-i4c-date-range <mifosx-i4c-date-range
label="Queued" label="Queued"
[range]="outboxDates" [range]="outboxDates"
@@ -96,6 +106,7 @@
<tr> <tr>
<th>Created At</th> <th>Created At</th>
<th>Complaint Ack No.</th> <th>Complaint Ack No.</th>
<th>Layer</th>
<th title="ACK: separate acknowledgement callback. RESPONSE: the Section 6 findings.">Type</th> <th title="ACK: separate acknowledgement callback. RESPONSE: the Section 6 findings.">Type</th>
<th>Delivery Status</th> <th>Delivery Status</th>
<th class="num">Attempts</th> <th class="num">Attempts</th>
@@ -121,6 +132,12 @@
{{ o.acknowledgement_no }} {{ o.acknowledgement_no }}
</a> </a>
</td> </td>
<td>
<span
class="layer-pill layer-pill--{{ o.layer === 0 ? 'victim' : o.layer === 1 ? 'first' : 'onward' }}"
>{{ layerLabel(o.layer) }}</span
>
</td>
<td> <td>
<span class="i4c-badge" [class.i4c-badge--blue]="o.message_type === 'ACK'">{{ <span class="i4c-badge" [class.i4c-badge--blue]="o.message_type === 'ACK'">{{
o.message_type === 'ACK' ? 'ACK' : 'RESPONSE' o.message_type === 'ACK' ? 'ACK' : 'RESPONSE'
@@ -158,7 +175,7 @@
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="10" class="i4c-empty"> <td colspan="11" class="i4c-empty">
<div class="empty-icon"><fa-icon icon="paper-plane"></fa-icon></div> <div class="empty-icon"><fa-icon icon="paper-plane"></fa-icon></div>
<div>No responses in the outbox queue matching current filter.</div> <div>No responses in the outbox queue matching current filter.</div>
</td> </td>
@@ -235,6 +252,16 @@
placeholder="e.g. 00, 11" placeholder="e.g. 00, 11"
/> />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Layer</mat-label>
<mat-select [(ngModel)]="logLayer" (selectionChange)="loadLog(true)">
<mat-option value="">All layers</mat-option>
<mat-option value="0">L0 · Victim</mat-option>
<mat-option value="1">L1 · First layer</mat-option>
<mat-option value="2">L2</mat-option>
<mat-option value="3">L3</mat-option>
</mat-select>
</mat-form-field>
<mifosx-i4c-date-range <mifosx-i4c-date-range
label="Call time" label="Call time"
[range]="logDates" [range]="logDates"
@@ -260,6 +287,7 @@
<th>HTTP Status</th> <th>HTTP Status</th>
<th>Response Code</th> <th>Response Code</th>
<th>Ack No.</th> <th>Ack No.</th>
<th>Layer</th>
<th class="num">Latency (ms)</th> <th class="num">Latency (ms)</th>
<th>Caller IP</th> <th>Caller IP</th>
<th>Error Details</th> <th>Error Details</th>
@@ -290,6 +318,16 @@
<td> <td>
<b>{{ l.acknowledgement_no || '-' }}</b> <b>{{ l.acknowledgement_no || '-' }}</b>
</td> </td>
<td>
@if (l.layer !== null && l.layer !== undefined) {
<span
class="layer-pill layer-pill--{{ l.layer === 0 ? 'victim' : l.layer === 1 ? 'first' : 'onward' }}"
>{{ layerLabel(l.layer) }}</span
>
} @else {
-
}
</td>
<td class="num">{{ l.duration_ms }} ms</td> <td class="num">{{ l.duration_ms }} ms</td>
<td>{{ l.client_ip || '-' }}</td> <td>{{ l.client_ip || '-' }}</td>
<td [class.err-text]="isFailedCall(l)" [class.note-text]="!isFailedCall(l)"> <td [class.err-text]="isFailedCall(l)" [class.note-text]="!isFailedCall(l)">
@@ -298,7 +336,7 @@
</tr> </tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="9" class="i4c-empty"> <td colspan="10" class="i4c-empty">
<div class="empty-icon"><fa-icon icon="history"></fa-icon></div> <div class="empty-icon"><fa-icon icon="history"></fa-icon></div>
<div>No API calls recorded.</div> <div>No API calls recorded.</div>
</td> </td>
@@ -189,3 +189,30 @@
color: #64748b; color: #64748b;
font-size: 12px; font-size: 12px;
} }
/* Layer of the complaint a row belongs to: victim (L0), first layer (L1), onward (L2+). */
.layer-pill {
display: inline-block;
padding: 2px 8px;
border-radius: 9999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
background: #f1f5f9;
color: #475569;
&--victim {
background: #eff6ff;
color: #1d4ed8;
}
&--first {
background: #fef3c7;
color: #b45309;
}
&--onward {
background: #f3e8ff;
color: #7e22ce;
}
}
@@ -66,6 +66,7 @@ export class I4cOutboxComponent implements OnInit {
outboxStatus = ''; outboxStatus = '';
outboxType = ''; outboxType = '';
outboxLayer = '';
outboxSearch = ''; outboxSearch = '';
outboxDates: I4cDateRange = { ...ALL_DATES }; outboxDates: I4cDateRange = { ...ALL_DATES };
outboxTotal = 0; outboxTotal = 0;
@@ -78,6 +79,7 @@ export class I4cOutboxComponent implements OnInit {
logSearch = ''; logSearch = '';
logHttpStatus = ''; logHttpStatus = '';
logResponseCode = ''; logResponseCode = '';
logLayer = '';
logDates: I4cDateRange = { ...ALL_DATES }; logDates: I4cDateRange = { ...ALL_DATES };
logTotal = 0; logTotal = 0;
logPageIndex = 0; logPageIndex = 0;
@@ -123,12 +125,19 @@ export class I4cOutboxComponent implements OnInit {
} }
get outboxHasFilters(): boolean { get outboxHasFilters(): boolean {
return !!(this.outboxStatus || this.outboxType || this.outboxSearch || this.outboxDates.preset !== 'ALL'); return !!(
this.outboxStatus ||
this.outboxType ||
this.outboxLayer ||
this.outboxSearch ||
this.outboxDates.preset !== 'ALL'
);
} }
clearOutboxFilters(): void { clearOutboxFilters(): void {
this.outboxStatus = ''; this.outboxStatus = '';
this.outboxType = ''; this.outboxType = '';
this.outboxLayer = '';
this.outboxSearch = ''; this.outboxSearch = '';
this.outboxDates = { ...ALL_DATES }; this.outboxDates = { ...ALL_DATES };
this.loadOutbox(true); this.loadOutbox(true);
@@ -152,6 +161,7 @@ export class I4cOutboxComponent implements OnInit {
this.logSearch || this.logSearch ||
this.logHttpStatus || this.logHttpStatus ||
this.logResponseCode || this.logResponseCode ||
this.logLayer ||
this.logDates.preset !== 'ALL' this.logDates.preset !== 'ALL'
); );
} }
@@ -162,6 +172,7 @@ export class I4cOutboxComponent implements OnInit {
this.logSearch = ''; this.logSearch = '';
this.logHttpStatus = ''; this.logHttpStatus = '';
this.logResponseCode = ''; this.logResponseCode = '';
this.logLayer = '';
this.logDates = { ...ALL_DATES }; this.logDates = { ...ALL_DATES };
this.loadLog(true); this.loadLog(true);
} }
@@ -177,6 +188,7 @@ export class I4cOutboxComponent implements OnInit {
.outbox({ .outbox({
status: this.outboxStatus, status: this.outboxStatus,
type: this.outboxType, type: this.outboxType,
layer: this.outboxLayer,
search: this.outboxSearch.trim(), search: this.outboxSearch.trim(),
fromDate: this.outboxDates.fromDate, fromDate: this.outboxDates.fromDate,
toDate: this.outboxDates.toDate, toDate: this.outboxDates.toDate,
@@ -207,6 +219,7 @@ export class I4cOutboxComponent implements OnInit {
search: this.logSearch.trim(), search: this.logSearch.trim(),
httpStatus: this.logHttpStatus, httpStatus: this.logHttpStatus,
responseCode: this.logResponseCode.trim(), responseCode: this.logResponseCode.trim(),
layer: this.logLayer,
fromDate: this.logDates.fromDate, fromDate: this.logDates.fromDate,
toDate: this.logDates.toDate, toDate: this.logDates.toDate,
offset: this.logPageIndex * this.logPageSize, offset: this.logPageIndex * this.logPageSize,
@@ -300,6 +313,14 @@ export class I4cOutboxComponent implements OnInit {
} }
} }
/** Layer 0 is the victim's own account, 1 the first-layer account, 2+ onward accounts in the trail. */
layerLabel(layer: number | null | undefined): string {
if (layer === null || layer === undefined) {
return '-';
}
return layer === 0 ? 'L0 · Victim' : layer === 1 ? 'L1 · First layer' : 'L' + layer + ' · Onward';
}
ok(status: number | null): boolean { ok(status: number | null): boolean {
return status !== null && status !== undefined && status >= 200 && status < 300; return status !== null && status !== undefined && status >= 200 && status < 300;
} }
@@ -130,6 +130,7 @@ export class I4cRequestDetailComponent implements OnInit {
return 'i4c-badge--green'; return 'i4c-badge--green';
case 'PARTIALLY_HELD': case 'PARTIALLY_HELD':
case 'PROSPECTIVE_HOLD': case 'PROSPECTIVE_HOLD':
case 'NOT_HELD_MOVED_ON':
return 'i4c-badge--amber'; return 'i4c-badge--amber';
case 'NOT_FOUND': case 'NOT_FOUND':
case 'NO_MATCH': case 'NO_MATCH':
@@ -205,15 +205,16 @@
[class.modal-tab--active]="modalTab === 'incidents'" [class.modal-tab--active]="modalTab === 'incidents'"
(click)="modalTab = 'incidents'" (click)="modalTab = 'incidents'"
> >
<fa-icon icon="tasks"></fa-icon> Incidents ({{ <fa-icon icon="tasks"></fa-icon> Incidents
selectedComplaint.incidents?.length || selectedComplaint.incidents || 0 <span class="tab-count">{{ selectedComplaint.incidents?.length || selectedComplaint.incidents || 0 }}</span>
}})
</button> </button>
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'findings'" (click)="modalTab = 'findings'"> <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 }}) <fa-icon icon="list-alt"></fa-icon> Section 6 Findings
<span class="tab-count">{{ selectedComplaint.findings?.length || 0 }}</span>
</button> </button>
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'outbox'" (click)="modalTab = 'outbox'"> <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 }}) <fa-icon icon="paper-plane"></fa-icon> Outbox Responses
<span class="tab-count">{{ selectedComplaint.outbox?.length || 0 }}</span>
</button> </button>
<button class="modal-tab" [class.modal-tab--active]="modalTab === 'payloads'" (click)="modalTab = 'payloads'"> <button class="modal-tab" [class.modal-tab--active]="modalTab === 'payloads'" (click)="modalTab = 'payloads'">
<fa-icon icon="code"></fa-icon> Raw JSON <fa-icon icon="code"></fa-icon> Raw JSON
@@ -58,6 +58,10 @@
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
} }
@if (role === 'BENEFICIARY_BANK') {
<mat-checkbox [(ngModel)]="overdueOnly" (change)="load(true)">Past 15-day deadline</mat-checkbox>
<mat-checkbox [(ngModel)]="contestedOnly" (change)="load(true)">Contested</mat-checkbox>
}
<mifosx-i4c-date-range label="Opened" [range]="dates" (rangeChange)="onDates($event)"></mifosx-i4c-date-range> <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"> <button mat-stroked-button class="i4c-filter-bar__clear" (click)="clearFilters()" [disabled]="!hasFilters">
<fa-icon icon="times"></fa-icon> Clear filters <fa-icon icon="times"></fa-icon> Clear filters
@@ -132,6 +136,19 @@
@if (r.bank_action && r.bank_action !== 'PENDING') { @if (r.bank_action && r.bank_action !== 'PENDING') {
<div class="sub">{{ r.bank_action }}</div> <div class="sub">{{ r.bank_action }}</div>
} }
@if (
r.compliance_due_on &&
r.role === 'BENEFICIARY_BANK' &&
r.status !== 'RESTORED' &&
r.status !== 'CLOSED'
) {
<div class="sub" [class.err-text]="r.compliance_overdue">
Credit by {{ r.compliance_due_on | date: 'dd MMM' }}
</div>
}
@if (r.contested) {
<span class="i4c-badge i4c-badge--red">CONTESTED</span>
}
@if (r.transfer_error) { @if (r.transfer_error) {
<div class="sub err-text">{{ r.transfer_error }}</div> <div class="sub err-text">{{ r.transfer_error }}</div>
} }
@@ -349,10 +366,14 @@
<mat-label>BNSS 106 notice no.</mat-label> <mat-label>BNSS 106 notice no.</mat-label>
<input matInput [(ngModel)]="request['bnssNoticeNo']" maxlength="100" /> <input matInput [(ngModel)]="request['bnssNoticeNo']" maxlength="100" />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mifosx-i4c-date-field
<mat-label>Notice date</mat-label> label="Notice date"
<input matInput type="date" [(ngModel)]="request['bnssNoticeDate']" /> [(value)]="request['bnssNoticeDate']"
</mat-form-field> ></mifosx-i4c-date-field>
<mifosx-i4c-date-field
label="Notice / order received (15-day clock)"
[(value)]="request['noticeReceivedOn']"
></mifosx-i4c-date-field>
<mat-checkbox [(ngModel)]="request['indemnityBondReceived']" <mat-checkbox [(ngModel)]="request['indemnityBondReceived']"
>Indemnity bond received (Rs 10 stamp paper)</mat-checkbox >Indemnity bond received (Rs 10 stamp paper)</mat-checkbox
> >
@@ -362,18 +383,18 @@
<mat-label>FIR no. (needed above ₹{{ r.firThreshold }})</mat-label> <mat-label>FIR no. (needed above ₹{{ r.firThreshold }})</mat-label>
<input matInput [(ngModel)]="request['firNo']" maxlength="100" /> <input matInput [(ngModel)]="request['firNo']" maxlength="100" />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mifosx-i4c-date-field
<mat-label>FIR date</mat-label> label="FIR date"
<input matInput type="date" [(ngModel)]="request['firDate']" /> [(value)]="request['firDate']"
</mat-form-field> ></mifosx-i4c-date-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Court order no.</mat-label> <mat-label>Court order no.</mat-label>
<input matInput [(ngModel)]="request['courtOrderNo']" maxlength="100" /> <input matInput [(ngModel)]="request['courtOrderNo']" maxlength="100" />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mifosx-i4c-date-field
<mat-label>Court order date</mat-label> label="Court order date"
<input matInput type="date" [(ngModel)]="request['courtOrderDate']" /> [(value)]="request['courtOrderDate']"
</mat-form-field> ></mifosx-i4c-date-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Court</mat-label> <mat-label>Court</mat-label>
<input matInput [(ngModel)]="request['courtName']" maxlength="200" /> <input matInput [(ngModel)]="request['courtName']" maxlength="200" />
@@ -425,12 +446,18 @@
₹{{ r.restorable_amount | number: '1.2-2' }} goes to {{ r.victim_name }} ({{ ₹{{ r.restorable_amount | number: '1.2-2' }} goes to {{ r.victim_name }} ({{
r.victim_account_no r.victim_account_no
}}{{ r.victim_ifsc ? ', ' + r.victim_ifsc : ', this bank' }}) once a second officer approves. }}{{ r.victim_ifsc ? ', ' + r.victim_ifsc : ', this bank' }}) once a second officer approves.
@if (r.victim_ifsc && !r.poolConfigured) { @if (r.victim_ifsc && !r.outwardReady) {
<b class="err-text" <b class="err-text"
>No restoration pool account is configured, so an outward restoration cannot be >Map the I4C_RESTORATION_PAYABLE and FUNDTRANSFER_OUTWARD_SUSPENSE GLs (Organisation >
approved.</b Clearing GL Configuration) before an outward restoration can be approved.</b
> >
} }
@if (r.compliance_due_on) {
<div [class.err-text]="isPast(r.compliance_due_on)">
SOP: credit the victim by {{ r.compliance_due_on | date: 'dd MMM yyyy' }} (15 days from
the notice).
</div>
}
</div> </div>
} @else if (action.action === 'NOT_COMPLIED' || action.action === 'REFER_BACK_LEA') { } @else if (action.action === 'NOT_COMPLIED' || action.action === 'REFER_BACK_LEA') {
<div class="sub"> <div class="sub">
@@ -462,13 +489,12 @@
@if (r.status === 'IN_POOL') { @if (r.status === 'IN_POOL') {
<div class="step"> <div class="step">
<div class="step__title"> <div class="step__title">
₹{{ r.restored_amount | number: '1.2-2' }} is in the restoration pool ₹{{ r.restored_amount | number: '1.2-2' }} is held in the restoration payable
</div> </div>
<div class="sub"> <div class="sub">
It left the suspect account on approval. The outward {{ r.transfer_channel }} to It left the suspect account on approval (Dr suspect, Cr I4C Restoration Payable). The outward
{{ r.victim_account_no }} / {{ r.victim_ifsc }} has not gone through yet{{ {{ r.transfer_channel }} to {{ r.victim_account_no }} / {{ r.victim_ifsc }} has not gone through
r.transfer_error ? ': ' + r.transfer_error : '.' yet{{ r.transfer_error ? ': ' + r.transfer_error : '.' }}
}}
</div> </div>
<div class="i4c-actions"> <div class="i4c-actions">
<button mat-flat-button color="primary" (click)="retryOutward()" [disabled]="busy"> <button mat-flat-button color="primary" (click)="retryOutward()" [disabled]="busy">
@@ -503,8 +529,214 @@
</div> </div>
</div> </div>
} }
@if ((r.status === 'RESTORED' || r.status === 'CLOSED') && r.restored_at) {
<div class="step">
<div class="step__title">
Suspect's challenge (SOP: within 15 days of the refund, until
{{ r.challenge_until | date: 'dd MMM yyyy' }})
</div>
@if (r.contested) {
<div class="i4c-note">
Contested on {{ r.contested_on | date: 'dd MMM yyyy' }}: {{ r.contest_remarks }}. The
investigation continues; the bank and the account holder cooperate with the IO.
</div>
} @else {
<div class="step__row">
<mifosx-i4c-date-field
label="Contested on"
[(value)]="contest.contestedOn"
></mifosx-i4c-date-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
<mat-label>Details</mat-label>
<input matInput [(ngModel)]="contest.remarks" maxlength="1000" />
</mat-form-field>
</div>
<div class="i4c-actions">
<button mat-stroked-button (click)="saveContest()" [disabled]="busy">Record challenge</button>
</div>
}
</div>
}
} @else { } @else {
@if (r.status !== 'CLOSED') { @if (r.status !== 'CLOSED') {
<div class="step">
<div class="step__title">
Customer's claim on the bank (RBI customer protection in fraudulent EBTs)
</div>
@if (r.liability_category) {
<div class="sub">
<b>{{
r.liability_regime === 'RBI_2026'
? 'RBI 2026 directions (RBI/2026-27/172)'
: 'RBI 2017 framework'
}}</b>
· {{ label(liabilityCategories, r.liability_category) }} · transaction
{{ r.txn_date | date: 'dd MMM yyyy' }} · reported
{{ r.customer_reported_on | date: 'dd MMM yyyy'
}}{{ r.cross_border ? ' · cross-border' : '' }}
</div>
<div class="sub">{{ r.liability_note }}</div>
<div class="sub">
@if (r.shadow_credit_due_on) {
Shadow reversal due
<b
[class.err-text]="isPast(r.shadow_credit_due_on) && r.shadow_credit_status !== 'CREDITED'"
>{{ r.shadow_credit_due_on | date: 'dd MMM yyyy' }}</b
>
·
}
Resolve by
<b [class.err-text]="isPast(r.resolution_due_on)">{{
r.resolution_due_on | date: 'dd MMM yyyy'
}}</b>
({{
r.liability_regime === 'RBI_2026'
? (r.cross_border ? '60' : '45') + ' calendar days'
: '90 days'
}})
</div>
}
<div class="step__row">
<mifosx-i4c-date-field
label="Transaction date"
[(value)]="liability.txnDate"
></mifosx-i4c-date-field>
<mifosx-i4c-date-field
label="Reported to the bank on"
[(value)]="liability.reportedOn"
></mifosx-i4c-date-field>
<mifosx-i4c-date-field
label="Reported to NCRP / 1930 on"
[(value)]="liability.ncrpReportedOn"
></mifosx-i4c-date-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="step__wide">
<mat-label>Caused by</mat-label>
<mat-select [(ngModel)]="liability.category">
@for (c of liabilityCategories; track c.value) {
<mat-option [value]="c.value">{{ c.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Gross loss (₹)</mat-label>
<input matInput type="number" min="0" [(ngModel)]="liability.grossLoss" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Recovered so far (₹)</mat-label>
<input matInput type="number" min="0" [(ngModel)]="liability.recoveredAmount" />
</mat-form-field>
<mat-checkbox [(ngModel)]="liability.crossBorder">Cross-border</mat-checkbox>
</div>
<div class="i4c-actions">
<button mat-stroked-button (click)="saveLiability()" [disabled]="busy">Assess liability</button>
</div>
@if (r.liability_category === 'CUSTOMER_NEGLIGENCE') {
<div class="sub">
<b>Compensation (RBI 2026, para 65.1T):</b>
@if (r.comp_eligible) {
₹{{ r.comp_amount | number: '1.2-2' }} ({{ r.comp_reason }}) · RBI ₹{{
r.comp_share_rbi | number: '1.2-2'
}}
· this bank ₹{{ r.comp_share_own | number: '1.2-2' }} · beneficiary bank ₹{{
r.comp_share_beneficiary | number: '1.2-2'
}}
} @else {
not eligible: {{ r.comp_reason }}
}
</div>
@if (r.comp_eligible && !r.comp_status) {
<button mat-stroked-button color="primary" (click)="applyCompensation()" [disabled]="busy">
Customer applied (pay within 5 days)
</button>
} @else if (r.comp_status === 'APPLIED') {
<div class="sub" [class.err-text]="isPast(r.comp_due_on)">
Applied {{ r.comp_applied_on | date: 'dd MMM yyyy' }}; pay by
{{ r.comp_due_on | date: 'dd MMM yyyy' }}.
</div>
<div class="i4c-actions">
<button mat-flat-button color="primary" (click)="payCompensation(true)" [disabled]="busy">
Pay compensation (checker)
</button>
<button mat-button color="warn" (click)="payCompensation(false)" [disabled]="busy">
Reject
</button>
</div>
} @else if (r.comp_status) {
<div class="sub">
Compensation {{ r.comp_status }} {{ r.comp_paid_on | date: 'dd MMM yyyy' }}
</div>
}
}
@if (r.liability_category && r.liability_category !== 'CUSTOMER_NEGLIGENCE') {
@if (!r.shadow_credit_status || r.shadow_credit_status === 'REJECTED') {
<div class="step__row">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Shadow reversal (₹)</mat-label>
<input matInput type="number" min="0" [(ngModel)]="shadowAmount" />
</mat-form-field>
<button
mat-flat-button
color="warn"
(click)="requestShadowCredit()"
[disabled]="busy || !shadowAmount"
>
Request shadow reversal (maker)
</button>
</div>
<div class="sub">
A provisional credit the customer cannot use until the case is decided (a lien is placed on
it).
</div>
} @else if (r.shadow_credit_status === 'PENDING') {
<div class="sub">
Shadow reversal of ₹{{ r.shadow_credit_amount | number: '1.2-2' }} requested by
{{ r.shadow_credit_requested_by }}; a different officer approves it.
</div>
<div class="i4c-actions">
<button
mat-flat-button
color="primary"
(click)="decideShadowCredit(true)"
[disabled]="busy"
>
Approve and credit (checker)
</button>
<button mat-button color="warn" (click)="decideShadowCredit(false)" [disabled]="busy">
Reject
</button>
</div>
} @else {
<div class="sub">
Shadow reversal ₹{{ r.shadow_credit_amount | number: '1.2-2' }} posted
{{ r.shadow_credited_at | date: 'dd MMM yyyy' }} by {{ r.shadow_credited_by }}
{{
r.shadow_finalised_at
? '· final ' + (r.shadow_finalised_at | date: 'dd MMM yyyy')
: '· provisional (lien on it)'
}}
@if (r.regularised_at) {
· regularised ₹{{ r.regularised_amount | number: '1.2-2' }} on
{{ r.regularised_at | date: 'dd MMM yyyy' }}
}
</div>
<div class="i4c-actions">
@if (!r.shadow_finalised_at && !r.regularised_at) {
<button mat-stroked-button color="primary" (click)="finaliseShadow()" [disabled]="busy">
Zero liability established: make final
</button>
}
@if (!r.regularised_at && r.received_amount) {
<button mat-stroked-button (click)="regularise()" [disabled]="busy">
Recover it from the restored money
</button>
}
</div>
}
}
</div>
<div class="step"> <div class="step">
<div class="step__title">Restored money received for our customer</div> <div class="step__title">Restored money received for our customer</div>
<div class="step__row"> <div class="step__row">
@@ -516,10 +748,10 @@
<mat-label>UTR / transaction id *</mat-label> <mat-label>UTR / transaction id *</mat-label>
<input matInput [(ngModel)]="receipt.utr" maxlength="40" /> <input matInput [(ngModel)]="receipt.utr" maxlength="40" />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mifosx-i4c-date-field
<mat-label>Credited on *</mat-label> label="Credited on *"
<input matInput type="date" [(ngModel)]="receipt.receivedOn" /> [(value)]="receipt.receivedOn"
</mat-form-field> ></mifosx-i4c-date-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Credited to account</mat-label> <mat-label>Credited to account</mat-label>
<input matInput [(ngModel)]="receipt.accountNo" maxlength="40" /> <input matInput [(ngModel)]="receipt.accountNo" maxlength="40" />
@@ -20,6 +20,7 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { I4cService, i4cErrorMessage } from '../i4c.service'; import { I4cService, i4cErrorMessage } from '../i4c.service';
import { I4cDateFieldComponent } from '../i4c-date-field/i4c-date-field.component';
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component'; import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component'; import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component';
@@ -35,7 +36,7 @@ export const MRM_STATUSES: { value: string; label: string }[] = [
{ value: 'ASSESSED', label: 'Assessed' }, { value: 'ASSESSED', label: 'Assessed' },
{ value: 'REQUESTED', label: 'Victim request received' }, { value: 'REQUESTED', label: 'Victim request received' },
{ value: 'AWAITING_APPROVAL', label: 'Awaiting checker' }, { value: 'AWAITING_APPROVAL', label: 'Awaiting checker' },
{ value: 'IN_POOL', label: 'In restoration pool' }, { value: 'IN_POOL', label: 'Held in restoration payable' },
{ value: 'TRANSFER_PENDING', label: 'Outward transfer pending' }, { value: 'TRANSFER_PENDING', label: 'Outward transfer pending' },
{ value: 'RESTORED', label: 'Restored' }, { value: 'RESTORED', label: 'Restored' },
{ value: 'AWAITED', label: 'Victim bank: awaited' }, { value: 'AWAITED', label: 'Victim bank: awaited' },
@@ -89,7 +90,8 @@ const VICTIM_STATUSES = [
MatPaginatorModule, MatPaginatorModule,
FaIconComponent, FaIconComponent,
I4cDateRangeComponent, I4cDateRangeComponent,
I4cDocumentsComponent I4cDocumentsComponent,
I4cDateFieldComponent
] ]
}) })
export class I4cRestorationsComponent implements OnInit { export class I4cRestorationsComponent implements OnInit {
@@ -112,6 +114,8 @@ export class I4cRestorationsComponent implements OnInit {
role = 'BENEFICIARY_BANK'; role = 'BENEFICIARY_BANK';
status = 'OPEN'; status = 'OPEN';
bankAction = ''; bankAction = '';
overdueOnly = false;
contestedOnly = false;
search = ''; search = '';
dates: I4cDateRange = { ...ALL_DATES }; dates: I4cDateRange = { ...ALL_DATES };
rows: any[] = []; rows: any[] = [];
@@ -151,6 +155,8 @@ export class I4cRestorationsComponent implements OnInit {
role: this.role, role: this.role,
status: this.status, status: this.status,
bankAction: this.bankAction, bankAction: this.bankAction,
overdue: this.overdueOnly || null,
contested: this.contestedOnly || null,
search: this.search.trim(), search: this.search.trim(),
fromDate: this.dates.fromDate, fromDate: this.dates.fromDate,
toDate: this.dates.toDate, toDate: this.dates.toDate,
@@ -187,12 +193,21 @@ export class I4cRestorationsComponent implements OnInit {
} }
get hasFilters(): boolean { get hasFilters(): boolean {
return !!(this.status !== 'OPEN' || this.bankAction || this.search || this.dates.preset !== 'ALL'); return !!(
this.status !== 'OPEN' ||
this.bankAction ||
this.overdueOnly ||
this.contestedOnly ||
this.search ||
this.dates.preset !== 'ALL'
);
} }
clearFilters(): void { clearFilters(): void {
this.status = 'OPEN'; this.status = 'OPEN';
this.bankAction = ''; this.bankAction = '';
this.overdueOnly = false;
this.contestedOnly = false;
this.search = ''; this.search = '';
this.dates = { ...ALL_DATES }; this.dates = { ...ALL_DATES };
this.load(true); this.load(true);
@@ -242,6 +257,7 @@ export class I4cRestorationsComponent implements OnInit {
courtName: r.court_name || '', courtName: r.court_name || '',
bnssNoticeNo: r.bnss_notice_no || '', bnssNoticeNo: r.bnss_notice_no || '',
bnssNoticeDate: r.bnss_notice_date || '', bnssNoticeDate: r.bnss_notice_date || '',
noticeReceivedOn: r.notice_received_on || '',
indemnityBondReceived: !!r.indemnity_bond_received indemnityBondReceived: !!r.indemnity_bond_received
}; };
this.action = { action: 'COMPLIED', reason: '' }; this.action = { action: 'COMPLIED', reason: '' };
@@ -362,6 +378,83 @@ export class I4cRestorationsComponent implements OnInit {
); );
} }
readonly liabilityCategories = [
{ value: 'BANK_NEGLIGENCE', label: 'Bank negligence / deficiency' },
{ value: 'THIRD_PARTY_BREACH', label: 'Third-party breach (neither bank nor customer)' },
{ value: 'CUSTOMER_NEGLIGENCE', label: 'Customer negligence (shared OTP / PIN, coerced)' }
];
contest = { contestedOn: '', remarks: '' };
liability = {
txnDate: '',
reportedOn: '',
ncrpReportedOn: '',
category: 'THIRD_PARTY_BREACH',
crossBorder: false,
grossLoss: null as number | null,
recoveredAmount: null as number | null
};
shadowAmount: number | null = null;
isPast(date: string | null | undefined): boolean {
return !!date && new Date(date).getTime() < Date.now();
}
saveContest(): void {
this.run(
this.i4c.restorationStep(this.selected.id, 'contest', this.contest),
'Challenge recorded; the investigation continues.'
);
}
saveLiability(): void {
this.run(
this.i4c.restorationStep(this.selected.id, 'liability', this.liability),
'Liability assessed under the RBI framework for that date.'
);
}
requestShadowCredit(): void {
this.run(
this.i4c.restorationStep(this.selected.id, 'shadow-credit', { amount: this.shadowAmount }),
'Shadow credit sent for approval.'
);
}
decideShadowCredit(approve: boolean): void {
this.run(
this.i4c.restorationStep(this.selected.id, 'shadow-credit/decision', { approve }),
approve ? 'Shadow credit posted to the customer.' : 'Shadow credit rejected.'
);
}
finaliseShadow(): void {
this.run(
this.i4c.restorationStep(this.selected.id, 'shadow-credit/finalise'),
'Shadow reversal made final; the customer can use it.'
);
}
applyCompensation(): void {
this.run(
this.i4c.restorationStep(this.selected.id, 'compensation/apply'),
'Application recorded; pay within 5 calendar days.'
);
}
payCompensation(approve: boolean): void {
this.run(
this.i4c.restorationStep(this.selected.id, 'compensation/decision', { approve }),
approve ? 'Compensation credited to the customer.' : 'Compensation rejected.'
);
}
regularise(): void {
this.run(
this.i4c.restorationStep(this.selected.id, 'regularise'),
'Shadow credit recovered from the restored money.'
);
}
loadProRata(): void { loadProRata(): void {
this.i4c.proRata(this.selected.hold_account_no).subscribe({ this.i4c.proRata(this.selected.hold_account_no).subscribe({
next: (p) => { next: (p) => {
@@ -0,0 +1,616 @@
<!--
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">
@for (t of tabs; track t.value) {
<button class="filter-chip" [class.filter-chip--active]="tab === t.value" (click)="setTab(t.value)">
<fa-icon [icon]="t.icon"></fa-icon> <span>{{ t.label }}</span>
</button>
}
</div>
<span class="i4c-toolbar__spacer"></span>
@if (tab === 'fraud-reports') {
<button mat-stroked-button (click)="downloadFraudCsv()">
<fa-icon icon="file-csv"></fa-icon> Download (CSV)
</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>Account / customer / reference</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="">Any</mat-option>
@for (s of statuses; track s) {
<mat-option [value]="s">{{ s }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (tab === 'fraud-reports') {
<mat-checkbox [(ngModel)]="overdueOnly" (change)="load(true)">FMR / STR overdue</mat-checkbox>
}
@if (tab === 'bank-holds') {
<mat-checkbox [(ngModel)]="overdueOnly" (change)="load(true)">Past a deadline</mat-checkbox>
<button mat-flat-button color="warn" (click)="showPlace = !showPlace">
<fa-icon icon="lock"></fa-icon> Place a hold
</button>
}
<mifosx-i4c-date-range label="Date" [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>
}
@if (tab === 'exclusions') {
<div class="i4c-card">
<div class="i4c-card__body">
<div class="i4c-form">
<mat-form-field appearance="outline">
<mat-label>Account no.</mat-label>
<input matInput [(ngModel)]="exclusionForm.accountNo" maxlength="40" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Kind</mat-label>
<mat-select [(ngModel)]="exclusionForm.kind">
<mat-option value="NODAL">Nodal account</mat-option>
<mat-option value="POOL">Pool account</mat-option>
<mat-option value="ESCROW">Escrow account</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="wide">
<mat-label>What it is (merchant nodal, aggregator pool...)</mat-label>
<input matInput [(ngModel)]="exclusionForm.reason" maxlength="500" />
</mat-form-field>
</div>
<button mat-flat-button color="primary" (click)="addExclusion()" [disabled]="busy">
Exclude from I4C holds
</button>
<div class="sub">
MHA SOP: holds are for first-layer mule accounts, not nodal, pool or escrow accounts. A complaint against an
excluded account is answered "hold not placed" and traced through the merchant settlement.
</div>
</div>
</div>
}
@if (tab === 'bank-holds' && showPlace) {
<div class="i4c-card">
<div class="i4c-card__head"><fa-icon icon="lock"></fa-icon> Hold a suspected mule transaction</div>
<div class="i4c-card__body">
<div class="i4c-form">
<mat-form-field appearance="outline">
<mat-label>Account no.</mat-label>
<input matInput [(ngModel)]="bankHoldForm.accountNo" maxlength="40" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Hold</mat-label>
<mat-select [(ngModel)]="bankHoldForm.scope">
<mat-option value="TRANSACTION">The flagged transaction's amount</mat-option>
<mat-option value="ACCOUNT">All debits on the account (last resort)</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Why flagged</mat-label>
<mat-select [(ngModel)]="bankHoldForm.trigger">
<mat-option value="PROFILE_MISMATCH">Does not match the customer's profile</mat-option>
<mat-option value="LINKED_TO_REPORTED_ACCOUNT">Linked to an account reported for fraud</mat-option>
<mat-option value="OTHER">Other</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Transaction ref</mat-label>
<input matInput [(ngModel)]="bankHoldForm.txnRef" maxlength="100" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Amount (₹, at least 1,000)</mat-label>
<input matInput type="number" min="1000" [(ngModel)]="bankHoldForm.amount" />
</mat-form-field>
<mat-form-field appearance="outline" class="wide">
<mat-label>Reason (given to the customer)</mat-label>
<input matInput [(ngModel)]="bankHoldForm.reason" maxlength="1000" />
</mat-form-field>
</div>
<button mat-flat-button color="warn" (click)="placeBankHold()" [disabled]="busy">Place hold now</button>
<div class="sub">
RBI draft (11.09.2026, effective 01.04.2027 if finalised): customer 20 days to explain; bank decides within 10
days of it (30 days from the hold without one); police referral through NCRP, 30 days to instruct; never more
than 60 days without an LEA instruction. The Hold Review job lifts holds at the end of those periods.
</div>
</div>
</div>
}
<div class="i4c-card">
<div class="i4c-card__head">
<fa-icon icon="user-shield"></fa-icon>
@switch (tab) {
@case ('mule-flags') {
<span>Mule-account register</span>
<span class="head-hint"
>Accounts in two or more complaints are flagged automatically; review each with EDD or the LEA.</span
>
}
@case ('fraud-reports') {
<span>Fraud reporting</span>
<span class="head-hint"
>Police / LEA, RBI fraud monitoring return (14 days), FIU-IND STR (7 days) from classification.</span
>
}
@case ('linked-alerts') {
<span>Fraud money that moved on inside this bank</span>
<span class="head-hint"
>Loan repayments (SOP 9.1(v): verify, hold, tell the linked account) and transfers to another account here
(the next layer is with us). Found by the I4C Hold Review job.</span
>
}
@case ('exclusions') {
<span>Nodal / pool / escrow accounts</span>
<span class="head-hint"
>Disputed amount is held; no prospective hold on future credits and normally no suspension (MHA SOP
9.1(iii)).</span
>
}
@case ('bank-holds') {
<span>Bank-initiated debit holds</span>
<span class="head-hint">Suspected mule transactions the bank holds itself (RBI draft mule SOP).</span>
}
@case ('channel-blocks') {
<span>Digital-channel suspensions</span>
<span class="head-hint"
>Placed and lifted through hold requests (maker-checker). CBS fund transfers refuse them.</span
>
}
}
</div>
<div class="i4c-table-wrap">
<table class="i4c-table">
<thead>
@switch (tab) {
@case ('mule-flags') {
<tr>
<th>Flagged</th>
<th>Account / Customer</th>
<th>Source</th>
<th class="num">Complaints</th>
<th>Reason</th>
<th>Status</th>
<th></th>
</tr>
}
@case ('fraud-reports') {
<tr>
<th>Classified</th>
<th>Account / Customer</th>
<th class="num">Amount (₹)</th>
<th>Police / LEA</th>
<th>RBI FMR</th>
<th>FIU-IND STR</th>
<th>Status</th>
<th></th>
</tr>
}
@case ('linked-alerts') {
<tr>
<th>Moved on</th>
<th>From (held account)</th>
<th>Moved to</th>
<th class="num">Amount (₹)</th>
<th>Status</th>
<th>Remarks</th>
<th></th>
</tr>
}
@case ('exclusions') {
<tr>
<th>Added</th>
<th>Account</th>
<th>Kind</th>
<th>What it is</th>
<th>Status</th>
<th></th>
</tr>
}
@case ('bank-holds') {
<tr>
<th>Placed</th>
<th>Account / Customer</th>
<th>Hold</th>
<th>Status</th>
<th>Next date</th>
<th>Customer told</th>
<th></th>
</tr>
}
@case ('channel-blocks') {
<tr>
<th>Placed</th>
<th>Account</th>
<th>Channels</th>
<th>Reason</th>
<th>Status</th>
<th>Lifted</th>
</tr>
}
}
</thead>
<tbody>
@for (r of rows; track r.id) {
@switch (tab) {
@case ('mule-flags') {
<tr>
<td>
{{ r.flagged_at | date: 'dd MMM yyyy' }}
<div class="sub">{{ r.flagged_by }}</div>
</td>
<td>
<b>{{ r.account_no }}</b>
<div class="sub">{{ r.client_name }} · {{ r.account_type }}</div>
</td>
<td>{{ r.source }}</td>
<td class="num">{{ r.reports_count }}</td>
<td class="wrap">
{{ r.reason }}
<div class="sub">{{ r.review_remarks }}</div>
</td>
<td>
<span
class="i4c-badge"
[class.i4c-badge--red]="r.status === 'CONFIRMED'"
[class.i4c-badge--amber]="r.status === 'SUSPECTED'"
>{{ r.status }}</span
>
</td>
<td>
@if (r.status === 'SUSPECTED') {
<button mat-stroked-button (click)="edit(r)">Review</button>
}
</td>
</tr>
}
@case ('fraud-reports') {
<tr>
<td>
{{ r.classified_on ? (r.classified_on | date: 'dd MMM yyyy') : 'Show-cause' }}
@if (r.show_cause_due_on && !r.classified_on) {
<div class="sub">reply due {{ r.show_cause_due_on | date: 'dd MMM' }}</div>
}
</td>
<td>
<b>{{ r.account_no }}</b>
<div class="sub">{{ r.client_name }} · {{ r.acknowledgement_nos }}</div>
</td>
<td class="num">{{ r.amount | number: '1.2-2' }}</td>
<td>
{{ r.police_ref || '-' }}
<div class="sub">{{ r.police_reported_on | date: 'dd MMM yyyy' }}</div>
</td>
<td [class.err-text]="!r.fmr_filed_on && isPast(r.fmr_due_on)">
{{ r.fmr_ref || 'due ' + (r.fmr_due_on | date: 'dd MMM') }}
<div class="sub">{{ r.fmr_filed_on | date: 'dd MMM yyyy' }}</div>
</td>
<td [class.err-text]="!r.str_filed_on && isPast(r.str_due_on)">
{{ r.str_ref || 'due ' + (r.str_due_on | date: 'dd MMM') }}
<div class="sub">{{ r.str_filed_on | date: 'dd MMM yyyy' }}</div>
</td>
<td>
<span class="i4c-badge" [class.i4c-badge--green]="r.status === 'CLOSED'">{{ r.status }}</span>
</td>
<td>
@if (r.status === 'OPEN') {
<button mat-stroked-button (click)="edit(r)">Update</button>
}
</td>
</tr>
}
@case ('linked-alerts') {
<tr>
<td>{{ r.moved_on | date: 'dd MMM yyyy' }}</td>
<td>
<b>{{ r.hold_account_no }}</b>
<div class="sub">Ack {{ r.acknowledgement_no }}</div>
</td>
<td>
{{ r.target_no }}
<div class="sub">
{{ r.kind === 'INTERNAL_TRANSFER' ? r.target_type + ' account here' : 'Loan repayment' }}
</div>
</td>
<td class="num">{{ r.amount | number: '1.2-2' }}</td>
<td>
<span class="i4c-badge" [class.i4c-badge--amber]="r.status === 'OPEN'">{{ r.status }}</span>
</td>
<td class="wrap">{{ r.remarks || '-' }}</td>
<td>
@if (r.status === 'OPEN') {
<button mat-stroked-button (click)="edit(r)">Action</button>
}
</td>
</tr>
}
@case ('exclusions') {
<tr>
<td>
{{ r.added_at | date: 'dd MMM yyyy' }}
<div class="sub">{{ r.added_by }}</div>
</td>
<td>
<b>{{ r.account_no }}</b>
<div class="sub">{{ r.account_type }}</div>
</td>
<td>{{ r.kind }}</td>
<td class="wrap">{{ r.reason }}</td>
<td>
<span class="i4c-badge" [class.i4c-badge--green]="r.status === 'ACTIVE'">{{ r.status }}</span>
</td>
<td>
@if (r.status === 'ACTIVE') {
<button mat-button color="warn" (click)="removeExclusion(r)" [disabled]="busy">Remove</button>
}
</td>
</tr>
}
@case ('bank-holds') {
<tr>
<td>
{{ r.placed_at | date: 'dd MMM yyyy' }}
<div class="sub">{{ r.placed_by }}</div>
</td>
<td>
<b>{{ r.account_no }}</b>
<div class="sub">{{ r.client_name }} · {{ r.trigger_type }}</div>
</td>
<td>
{{ r.scope === 'ACCOUNT' ? 'All debits' : '₹' + (r.hold_amount | number: '1.2-2') }}
<div class="sub">
txn {{ r.flagged_txn_ref || '-' }} · ₹{{ r.flagged_amount | number: '1.2-2' }}
</div>
</td>
<td>
<span
class="i4c-badge"
[class.i4c-badge--red]="r.status === 'HELD' || r.status === 'REFERRED'"
[class.i4c-badge--green]="r.status === 'LIFTED'"
>{{ r.status }}</span
>
@if (r.lift_reason) {
<div class="sub">{{ r.lift_reason }}</div>
}
</td>
<td [class.err-text]="r.due">
@switch (r.status) {
@case ('HELD') {
explain by {{ r.explanation_due_on | date: 'dd MMM' }} · decide by
{{ r.decision_due_on | date: 'dd MMM' }}
}
@case ('EXPLAINED') {
decide by {{ r.decision_due_on | date: 'dd MMM' }}
}
@case ('REFERRED') {
police by {{ r.police_due_on | date: 'dd MMM' }} ({{ r.ncrp_ref }})
}
@default {
{{
r.status === 'LIFTED'
? (r.lifted_at | date: 'dd MMM yyyy')
: 'limit ' + (r.max_until | date: 'dd MMM')
}}
}
}
@if (r.grievance_raised_on && !r.grievance_resolved_on) {
<div class="sub">grievance due {{ r.grievance_due_on | date: 'dd MMM' }}</div>
}
</td>
<td>
{{
r.customer_notified_at
? (r.customer_notified_at | date: 'dd MMM') + ' · ' + r.notification_mode
: 'Not yet'
}}
</td>
<td>
@if (r.status !== 'LIFTED' || (r.grievance_raised_on && !r.grievance_resolved_on)) {
<button mat-stroked-button (click)="openStep(r)">Next step</button>
}
</td>
</tr>
}
@case ('channel-blocks') {
<tr>
<td>
{{ r.placed_at | date: 'dd MMM yyyy' }}
<div class="sub">{{ r.placed_by }}</div>
</td>
<td>
<b>{{ r.account_no }}</b>
<div class="sub">{{ r.account_type }}</div>
</td>
<td class="wrap">{{ r.channels }}</td>
<td class="wrap">{{ r.reason }}</td>
<td>
<span class="i4c-badge" [class.i4c-badge--red]="r.status === 'ACTIVE'">{{ r.status }}</span>
</td>
<td>{{ r.lifted_at ? (r.lifted_at | date: 'dd MMM yyyy') + ' · ' + r.lifted_by : '-' }}</td>
</tr>
}
}
@if (editing?.id === r.id && tab === 'bank-holds') {
<tr>
<td [attr.colspan]="8">
<div class="i4c-form">
<mat-form-field appearance="outline">
<mat-label>Step</mat-label>
<mat-select [(ngModel)]="step" (selectionChange)="form = {}">
@for (b of bankSteps; track b.value) {
<mat-option [value]="b.value">{{ b.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@switch (step) {
@case ('notice') {
<mat-form-field appearance="outline">
<mat-label>How (PHONE, LETTER...)</mat-label>
<input matInput [(ngModel)]="form['mode']" maxlength="20" />
</mat-form-field>
<mat-form-field appearance="outline" class="wide">
<mat-label>Reference</mat-label>
<input matInput [(ngModel)]="form['reference']" maxlength="200" />
</mat-form-field>
}
@case ('explanation') {
<mifosx-i4c-date-field
label="Received on"
[(value)]="form['receivedOn']"
></mifosx-i4c-date-field>
<mat-form-field appearance="outline" class="wide">
<mat-label>Explanation</mat-label>
<input matInput [(ngModel)]="form['explanation']" maxlength="2000" />
</mat-form-field>
}
@case ('decision') {
<mat-form-field appearance="outline">
<mat-label>Decision</mat-label>
<mat-select [(ngModel)]="form['decision']">
<mat-option value="LIFT">Satisfied: lift</mat-option>
<mat-option value="REFER">Not satisfied: refer to police</mat-option>
</mat-select>
</mat-form-field>
@if (form['decision'] === 'REFER') {
<mat-form-field appearance="outline">
<mat-label>NCRP reference</mat-label>
<input matInput [(ngModel)]="form['ncrpRef']" maxlength="100" />
</mat-form-field>
}
<mat-form-field appearance="outline" class="wide">
<mat-label>Basis</mat-label>
<input matInput [(ngModel)]="form['remarks']" maxlength="1000" />
</mat-form-field>
}
@case ('police') {
<mat-form-field appearance="outline">
<mat-label>Instruction</mat-label>
<mat-select [(ngModel)]="form['instruction']">
<mat-option value="CONTINUE">Continue the hold</mat-option>
<mat-option value="LIFT">Lift the hold</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="wide">
<mat-label>Police reference</mat-label>
<input matInput [(ngModel)]="form['reference']" maxlength="200" />
</mat-form-field>
}
@case ('grievance') {
<mat-form-field appearance="outline" class="wide">
<mat-label>Customer's grievance (resolve within 30 days)</mat-label>
<input matInput [(ngModel)]="form['text']" maxlength="2000" />
</mat-form-field>
}
@case ('grievance-resolution') {
<mat-form-field appearance="outline" class="wide">
<mat-label>Resolution</mat-label>
<input matInput [(ngModel)]="form['resolution']" maxlength="2000" />
</mat-form-field>
}
}
</div>
<div class="i4c-actions">
<button mat-flat-button color="primary" (click)="saveStep()" [disabled]="busy">Save</button>
<button mat-button (click)="editing = null">Cancel</button>
</div>
</td>
</tr>
}
@if (editing?.id === r.id && tab !== 'bank-holds') {
<tr>
<td [attr.colspan]="8">
<div class="i4c-form">
@if (tab === 'fraud-reports') {
<mat-form-field appearance="outline">
<mat-label>Police / LEA ref</mat-label>
<input matInput [(ngModel)]="form['policeRef']" maxlength="200" />
</mat-form-field>
<mifosx-i4c-date-field
label="Reported on"
[(value)]="form['policeReportedOn']"
></mifosx-i4c-date-field>
<mat-form-field appearance="outline">
<mat-label>FMR ref</mat-label>
<input matInput [(ngModel)]="form['fmrRef']" maxlength="100" />
</mat-form-field>
<mifosx-i4c-date-field
label="FMR filed on"
[(value)]="form['fmrFiledOn']"
></mifosx-i4c-date-field>
<mat-form-field appearance="outline">
<mat-label>STR ref</mat-label>
<input matInput [(ngModel)]="form['strRef']" maxlength="100" />
</mat-form-field>
<mifosx-i4c-date-field
label="STR filed on"
[(value)]="form['strFiledOn']"
></mifosx-i4c-date-field>
} @else {
<mat-form-field appearance="outline">
<mat-label>Outcome</mat-label>
<mat-select [(ngModel)]="form['outcome']">
@if (tab === 'mule-flags') {
<mat-option value="CONFIRMED">Confirmed mule</mat-option>
<mat-option value="CLEARED">Not a mule (clear)</mat-option>
} @else {
<mat-option value="ACTIONED">Actioned</mat-option>
<mat-option value="DISMISSED">Dismissed</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="wide">
<mat-label>Remarks</mat-label>
<input matInput [(ngModel)]="form['remarks']" maxlength="1000" />
</mat-form-field>
</div>
<div class="i4c-actions">
<button mat-flat-button color="primary" (click)="save()" [disabled]="busy">Save</button>
<button mat-button (click)="editing = null">Cancel</button>
</div>
</td>
</tr>
}
} @empty {
<tr>
<td colspan="8" class="i4c-empty">Nothing here for 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="Register pages"
></mat-paginator>
</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;
.err-text {
color: #dc2626;
}
.wide {
flex: 1 1 280px;
}
@@ -0,0 +1,313 @@
/**
* 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 { 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, saveBlob } from '../i4c.service';
import { I4cDateFieldComponent } from '../i4c-date-field/i4c-date-field.component';
import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component';
type Register = 'mule-flags' | 'fraud-reports' | 'linked-alerts' | 'channel-blocks' | 'exclusions' | 'bank-holds';
/**
* The bank's own duties around suspect accounts (MHA SOP, RBI fraud and AML rules): the mule-account register, fraud
* reporting to police / RBI (FMR) / FIU-IND (STR) with due dates, fraud money that repaid loans, and digital-channel
* suspensions.
*/
@Component({
selector: 'mifosx-i4c-risk',
templateUrl: './i4c-risk.component.html',
styleUrls: ['./i4c-risk.component.scss'],
imports: [
DatePipe,
DecimalPipe,
FormsModule,
MatButtonModule,
MatCheckboxModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatProgressBarModule,
MatPaginatorModule,
FaIconComponent,
I4cDateRangeComponent,
I4cDateFieldComponent
]
})
export class I4cRiskComponent implements OnInit {
private i4c = inject(I4cService);
private snackBar = inject(MatSnackBar);
readonly tabs: { value: Register; label: string; icon: string; statuses: string[] }[] = [
{ value: 'mule-flags', label: 'Mule register', icon: 'user-shield', statuses: [
'SUSPECTED',
'CONFIRMED',
'CLEARED'
] },
{ value: 'fraud-reports', label: 'Fraud reporting (FMR / STR)', icon: 'file-alt', statuses: [
'SHOW_CAUSE',
'OPEN',
'CLOSED'
] },
{ value: 'linked-alerts', label: 'Loan repayments', icon: 'exchange-alt', statuses: [
'OPEN',
'ACTIONED',
'DISMISSED'
] },
{ value: 'channel-blocks', label: 'Digital suspensions', icon: 'ban', statuses: [
'ACTIVE',
'LIFTED'
] },
{ value: 'bank-holds', label: 'Bank-initiated holds (RBI draft)', icon: 'lock', statuses: [
'OPEN',
'HELD',
'EXPLAINED',
'REFERRED',
'CONTINUED',
'LIFTED'
] },
{ value: 'exclusions', label: 'Nodal / pool / escrow', icon: 'university', statuses: [
'ACTIVE',
'REMOVED'
] }
];
exclusionForm = { accountNo: '', kind: 'NODAL', reason: '' };
bankHoldForm = {
accountNo: '',
scope: 'TRANSACTION',
trigger: 'PROFILE_MISMATCH',
txnRef: '',
amount: null as number | null,
reason: ''
};
showPlace = false;
step = '';
readonly bankSteps: { value: string; label: string }[] = [
{ value: 'notice', label: 'Customer told (by hand)' },
{ value: 'explanation', label: "Customer's explanation" },
{ value: 'decision', label: 'Decision: lift or refer to police' },
{ value: 'police', label: "Police's instruction" },
{ value: 'grievance', label: 'Customer grievance' },
{ value: 'grievance-resolution', label: 'Grievance resolved' }
];
readonly pageSizes = [
10,
20,
50,
100
];
tab: Register = 'mule-flags';
status = '';
overdueOnly = false;
search = '';
dates: I4cDateRange = { ...ALL_DATES };
rows: any[] = [];
total = 0;
pageIndex = 0;
pageSize = 20;
loading = false;
busy = false;
error = '';
editing: any = null;
form: Record<string, any> = {};
ngOnInit(): void {
this.load();
}
get statuses(): string[] {
return this.tabs.find((t) => t.value === this.tab)?.statuses || [];
}
setTab(t: Register): void {
this.tab = t;
this.status = '';
this.overdueOnly = false;
this.editing = null;
this.load(true);
}
load(fromFirstPage = false): void {
if (fromFirstPage) {
this.pageIndex = 0;
}
this.loading = true;
this.error = '';
this.i4c
.register(this.tab, {
status: this.status,
overdue: (this.tab === 'fraud-reports' && this.overdueOnly) || null,
due: (this.tab === 'bank-holds' && 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;
}
});
}
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');
}
clearFilters(): void {
this.status = '';
this.overdueOnly = false;
this.search = '';
this.dates = { ...ALL_DATES };
this.load(true);
}
isPast(date: string | null | undefined): boolean {
return !!date && new Date(date).getTime() < Date.now();
}
edit(row: any): void {
this.editing = row;
if (this.tab === 'mule-flags') {
this.form = { outcome: 'CONFIRMED', remarks: '' };
} else if (this.tab === 'fraud-reports') {
this.form = {
policeRef: row.police_ref || '',
policeReportedOn: row.police_reported_on || '',
fmrRef: row.fmr_ref || '',
fmrFiledOn: row.fmr_filed_on || '',
strRef: row.str_ref || '',
strFiledOn: row.str_filed_on || '',
remarks: row.remarks || ''
};
} else {
this.form = { outcome: 'ACTIONED', remarks: '' };
}
}
save(): void {
const row = this.editing;
const call =
this.tab === 'mule-flags'
? this.i4c.casePost('mule-flags/' + row.id + '/review', this.form)
: this.tab === 'fraud-reports'
? this.i4c.casePut('fraud-reports/' + row.id, this.form)
: this.i4c.casePost('linked-alerts/' + row.id, this.form);
this.busy = true;
call.subscribe({
next: () => {
this.busy = false;
this.editing = null;
this.snackBar.open('Saved.', 'OK', { duration: 3000 });
this.load();
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
}
});
}
private post(path: string, body: Record<string, any>, done: string, after?: () => void): void {
this.busy = true;
this.i4c.casePost(path, body).subscribe({
next: () => {
this.busy = false;
this.snackBar.open(done, 'OK', { duration: 4000 });
after?.();
this.load();
},
error: (e) => {
this.busy = false;
this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 });
}
});
}
addExclusion(): void {
this.post('exclusions', this.exclusionForm, 'Account excluded from I4C holds.', () => {
this.exclusionForm = { accountNo: '', kind: 'NODAL', reason: '' };
});
}
removeExclusion(row: any): void {
this.post(
'exclusions/' + row.id + '/remove',
{},
'Exclusion removed; I4C complaints will hold this account again.'
);
}
placeBankHold(): void {
this.post('bank-holds', this.bankHoldForm, 'Hold placed; the customer is told on the automatic channels.', () => {
this.showPlace = false;
this.bankHoldForm = {
accountNo: '',
scope: 'TRANSACTION',
trigger: 'PROFILE_MISMATCH',
txnRef: '',
amount: null,
reason: ''
};
});
}
openStep(row: any): void {
this.editing = row;
this.step =
row.status === 'HELD'
? 'explanation'
: row.status === 'EXPLAINED'
? 'decision'
: row.status === 'REFERRED'
? 'police'
: 'grievance';
this.form = {};
}
saveStep(): void {
this.post('bank-holds/' + this.editing.id + '/' + this.step, this.form, 'Recorded.', () => (this.editing = null));
}
downloadFraudCsv(): void {
this.i4c.fraudReportsCsv().subscribe({
next: (blob) => saveBlob(blob, 'i4c-fraud-reports-' + new Date().toISOString().slice(0, 10) + '.csv'),
error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 })
});
}
}
@@ -124,23 +124,23 @@
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 2px 8px rgb(15 23 42 / 3%); box-shadow: 0 2px 8px rgb(15 23 42 / 3%);
overflow-x: auto;
/* No horizontal scroll: tabs wrap onto a second row when the screen is narrow. */
&__links { &__links {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 4px; gap: 2px;
width: 100%; width: 100%;
} }
&__link { &__link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 5px;
padding: 6px 12px; padding: 5px 9px;
border-radius: 8px; border-radius: 8px;
font-size: 11.5px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: #475569; color: #475569;
text-decoration: none; text-decoration: none;
@@ -162,7 +162,8 @@
} }
} }
&--active { &--active,
&--active:hover {
background: #2563eb; background: #2563eb;
color: #fff !important; color: #fff !important;
box-shadow: 0 3px 10px rgb(37 99 235 / 28%); box-shadow: 0 3px 10px rgb(37 99 235 / 28%);
@@ -43,6 +43,7 @@ export class I4cShellComponent implements OnInit {
{ path: 'grievances', label: 'GRM Grievances', icon: 'balance-scale' }, { path: 'grievances', label: 'GRM Grievances', icon: 'balance-scale' },
{ path: 'restorations', label: 'MRM Restorations', icon: 'hand-holding-usd' }, { path: 'restorations', label: 'MRM Restorations', icon: 'hand-holding-usd' },
{ path: 'mrm-report', label: 'MRM Balance Report', icon: 'file-csv' }, { path: 'mrm-report', label: 'MRM Balance Report', icon: 'file-csv' },
{ path: 'risk', label: 'Risk Registers', icon: 'user-shield' },
{ path: 'outbox', label: 'Responses & API Log', icon: 'paper-plane' }, { path: 'outbox', label: 'Responses & API Log', icon: 'paper-plane' },
{ path: 'bank-master', label: 'Bank Code Master', icon: 'university' }, { path: 'bank-master', label: 'Bank Code Master', icon: 'university' },
{ path: 'intimation-settings', label: 'Intimation Settings', icon: 'cog' } { path: 'intimation-settings', label: 'Intimation Settings', icon: 'cog' }
+47 -1
View File
@@ -50,6 +50,21 @@ export interface I4cHold {
documents?: I4cDocument[]; documents?: I4cDocument[];
released_amount?: number; released_amount?: number;
restored_amount?: number; restored_amount?: number;
digital_suspended?: boolean;
lapse_on?: string;
lea_notice_due_on?: string;
lea_notice_sent_on?: string;
lea_notice_ref?: string;
extension_until?: string;
extension_ref?: string;
linkedAlerts?: any[];
relatedAccounts?: any[];
excludedKind?: { kind: string }[];
muleFlags?: any[];
fraudReports?: any[];
channelBlocks?: any[];
reportsOnAccount?: number;
canLapseRelease?: boolean;
auto_intimation_attempts?: number; auto_intimation_attempts?: number;
} }
@@ -141,7 +156,7 @@ export interface I4cPage<T> {
export type I4cListParams = Record<string, string | number | boolean | null | undefined>; export type I4cListParams = Record<string, string | number | boolean | null | undefined>;
export interface HoldActionRequest { export interface HoldActionRequest {
action: 'RELEASE' | 'FREEZE' | 'UNFREEZE' | 'UNFREEZE_AND_RELEASE'; action: 'RELEASE' | 'FREEZE' | 'UNFREEZE' | 'UNFREEZE_AND_RELEASE' | 'SUSPEND_DIGITAL' | 'RESTORE_DIGITAL';
/** RELEASE only: how much to release; empty releases all. */ /** RELEASE only: how much to release; empty releases all. */
amount?: number | null; amount?: number | null;
reasonType: string; reasonType: string;
@@ -259,6 +274,19 @@ export class I4cService {
return this.http.get<I4cPage<any>>(`${this.base}/bank-master`, { params: this.params(query) }); return this.http.get<I4cPage<any>>(`${this.base}/bank-master`, { params: this.params(query) });
} }
/** The bank's IFSC / prefix -> I4C Bank Code map, and reported IFSCs that had no code. */
ifscMap(): Observable<{ mappings: any[]; unknown: any[] }> {
return this.http.get<{ mappings: any[]; unknown: any[] }>(`${this.cases}/ifsc-map`);
}
mapIfsc(body: { ifscKey: string; bankCode: number; note?: string }): Observable<{ resourceId: number }> {
return this.http.post<{ resourceId: number }>(`${this.cases}/ifsc-map`, body);
}
unmapIfsc(id: number): Observable<{ resourceId: number }> {
return this.http.post<{ resourceId: number }>(`${this.cases}/ifsc-map/${id}/remove`, {});
}
// ── GRM / MRM (/v1/i4c/cases) ────────────────────────────────────────── // ── GRM / MRM (/v1/i4c/cases) ──────────────────────────────────────────
grievances(query: I4cListParams = {}): Observable<I4cPage<any>> { grievances(query: I4cListParams = {}): Observable<I4cPage<any>> {
@@ -335,6 +363,24 @@ export class I4cService {
}); });
} }
/** A POST on /i4c/cases/{path}, e.g. restorations/5/contest or holds/3/lea-notice. */
casePost(path: string, body: Record<string, any> = {}): Observable<any> {
return this.http.post<any>(`${this.cases}/${path}`, body);
}
casePut(path: string, body: Record<string, any> = {}): Observable<any> {
return this.http.put<any>(`${this.cases}/${path}`, body);
}
/** A paged register on /i4c/cases: mule-flags, fraud-reports, linked-alerts, channel-blocks. */
register(name: string, query: I4cListParams = {}): Observable<I4cPage<any>> {
return this.http.get<I4cPage<any>>(`${this.cases}/${name}`, { params: this.params(query) });
}
fraudReportsCsv(): Observable<Blob> {
return this.http.get(`${this.cases}/fraud-reports.csv`, { responseType: 'blob' });
}
documents(ownerType: I4cDocumentOwner, ownerId: number): Observable<I4cDocument[]> { documents(ownerType: I4cDocumentOwner, ownerId: number): Observable<I4cDocument[]> {
return this.http.get<I4cDocument[]>(`${this.cases}/documents`, { params: this.params({ ownerType, ownerId }) }); return this.http.get<I4cDocument[]>(`${this.cases}/documents`, { params: this.params({ ownerType, ownerId }) });
} }