From 96bebd339b07900dcacc311af7d9a637d09ab1c1 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Mon, 26 Oct 2026 11:25:40 +0530 Subject: [PATCH] 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 --- src/app/cfcfrms/cfcfrms-routing.module.ts | 6 + src/app/cfcfrms/i4c/_i4c-cases.scss | 3 +- src/app/cfcfrms/i4c/_i4c.scss | 185 ++- .../i4c-bank-master.component.html | 97 ++ .../i4c-bank-master.component.scss | 12 + .../i4c-bank-master.component.ts | 53 + .../i4c-dashboard.component.html | 96 ++ .../i4c-date-field.component.html | 22 + .../i4c-date-field.component.scss | 17 + .../i4c-date-field.component.ts | 67 ++ .../i4c-documents.component.html | 191 ++- .../i4c-documents.component.scss | 410 ++++++- .../i4c-documents/i4c-documents.component.ts | 5 + .../i4c-grievances.component.html | 20 +- .../i4c-grievances.component.scss | 4 + .../i4c-grievances.component.ts | 10 +- .../i4c/i4c-holds/i4c-holds.component.html | 1047 +++++++++++++++-- .../i4c/i4c-holds/i4c-holds.component.scss | 810 ++++++++++++- .../i4c/i4c-holds/i4c-holds.component.ts | 118 +- .../i4c/i4c-outbox/i4c-outbox.component.html | 42 +- .../i4c/i4c-outbox/i4c-outbox.component.scss | 27 + .../i4c/i4c-outbox/i4c-outbox.component.ts | 23 +- .../i4c-request-detail.component.ts | 1 + .../i4c-requests/i4c-requests.component.html | 11 +- .../i4c-restorations.component.html | 280 ++++- .../i4c-restorations.component.ts | 99 +- .../i4c/i4c-risk/i4c-risk.component.html | 616 ++++++++++ .../i4c/i4c-risk/i4c-risk.component.scss | 21 + .../i4c/i4c-risk/i4c-risk.component.ts | 313 +++++ .../i4c/i4c-shell/i4c-shell.component.scss | 15 +- .../i4c/i4c-shell/i4c-shell.component.ts | 1 + src/app/cfcfrms/i4c/i4c.service.ts | 48 +- 32 files changed, 4368 insertions(+), 302 deletions(-) create mode 100644 src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.ts diff --git a/src/app/cfcfrms/cfcfrms-routing.module.ts b/src/app/cfcfrms/cfcfrms-routing.module.ts index 16db64820..059a646f7 100644 --- a/src/app/cfcfrms/cfcfrms-routing.module.ts +++ b/src/app/cfcfrms/cfcfrms-routing.module.ts @@ -31,6 +31,7 @@ import { I4cIntimationSettingsComponent } from './i4c/i4c-intimation-settings/i4 import { I4cGrievancesComponent } from './i4c/i4c-grievances/i4c-grievances.component'; import { I4cRestorationsComponent } from './i4c/i4c-restorations/i4c-restorations.component'; import { I4cMrmReportComponent } from './i4c/i4c-mrm-report/i4c-mrm-report.component'; +import { I4cRiskComponent } from './i4c/i4c-risk/i4c-risk.component'; const routes: Routes = [ Route.withShell([ @@ -143,6 +144,11 @@ const routes: Routes = [ component: I4cMrmReportComponent, 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', component: I4cIntimationSettingsComponent, diff --git a/src/app/cfcfrms/i4c/_i4c-cases.scss b/src/app/cfcfrms/i4c/_i4c-cases.scss index 108527d8e..47bf346e8 100644 --- a/src/app/cfcfrms/i4c/_i4c-cases.scss +++ b/src/app/cfcfrms/i4c/_i4c-cases.scss @@ -76,7 +76,8 @@ gap: 10px; align-items: center; - mat-form-field { + mat-form-field, + mifosx-i4c-date-field { width: 220px; } } diff --git a/src/app/cfcfrms/i4c/_i4c.scss b/src/app/cfcfrms/i4c/_i4c.scss index b9b36cdc2..aaf94edc0 100644 --- a/src/app/cfcfrms/i4c/_i4c.scss +++ b/src/app/cfcfrms/i4c/_i4c.scss @@ -609,7 +609,7 @@ animation: i4c-modal-pop 0.25s cubic-bezier(0.16, 1, 0.3, 1); &--wide { - max-width: 860px; + max-width: min(980px, 95vw); } &--full { @@ -679,42 +679,197 @@ } } + /* Modern Modal Tabs Bar */ .i4c-modal-tabs { display: flex; + flex-wrap: wrap; align-items: center; gap: 6px; - padding: 6px 16px; - background: #f8fafc; + padding: 8px 14px; + background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%); border-bottom: 1px solid #e2e8f0; - overflow-x: auto; flex-shrink: 0; .modal-tab { + position: relative; display: inline-flex; align-items: center; - gap: 5px; - padding: 4px 10px; - border-radius: 6px; - font-size: 11px; + gap: 6px; + padding: 6px 12px; + border-radius: 8px; + font-size: 12px; font-weight: 600; - color: #64748b; + color: #475569; background: transparent; border: 1px solid transparent; cursor: pointer; - transition: all 0.2s ease; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); 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 { color: #0f172a; - background: #e2e8f0; + background: rgb(226 232 240 / 70%); + + fa-icon { + color: #1e293b; + } } - &--active { - color: #2563eb; + &--active, + &--active:hover { + color: #1d4ed8; background: #fff; - border-color: #e2e8f0; - box-shadow: 0 1px 3px rgb(15 23 42 / 5%); + border-color: #cbd5e1; + 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; } } diff --git a/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.html b/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.html index c3739396f..334b37c85 100644 --- a/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.html +++ b/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.html @@ -60,6 +60,103 @@ } + +
+
+ + IFSC to I4C Bank Code map + 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. +
+
+ + IFSC prefix or full IFSC + + + + I4C Bank Code + + + + Note + + + +
+ @if (mapError) { +
+ + {{ mapError }} +
+ } +
+ + + + + + + + + + + + + @for (m of mappings; track m.id) { + + + + + + + + + } @empty { + + + + } + +
IFSC / prefixBank CodeBankNoteAdded
{{ m.ifsc_key }}{{ m.bank_code }}{{ m.bank_name || '-' }}{{ m.note || '-' }}{{ m.added_at | date: 'dd MMM yyyy' }} · {{ m.added_by }}
No mappings. The master's IFSC prefixes are used.
+
+ @if (unknown.length) { +
+ Reported with no bank code (payee_bank_code 0) + Map these so later responses name the next bank. +
+
+ + + + + + + + + + + @for (u of unknown; track u.ifsc) { + + + + + + + } + +
IFSCTimesLast seen
{{ u.ifsc || '(none given)' }}{{ u.times }}{{ u.last_seen | date: 'dd MMM yyyy' }} + @if (u.ifsc) { + + } +
+
+ } +
+
diff --git a/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.scss b/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.scss index 1e69bd623..d52ba428f 100644 --- a/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.scss +++ b/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.scss @@ -358,3 +358,15 @@ .ifsc-filter { 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; +} diff --git a/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.ts b/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.ts index cdb639638..12d223068 100644 --- a/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.ts +++ b/src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.ts @@ -66,8 +66,61 @@ export class I4cBankMasterComponent implements OnInit { copiedKey = ''; selectedBankModal: any = null; + mappings: any[] = []; + unknown: any[] = []; + mapKey = ''; + mapCode: number | null = null; + mapNote = ''; + mapBusy = false; + mapError = ''; + ngOnInit(): void { 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 { diff --git a/src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.html b/src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.html index 9889d74ca..6397558cd 100644 --- a/src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.html +++ b/src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.html @@ -195,6 +195,102 @@
+ + +
+
+ + GRM & MRM +
+ +
+ + +
+
+ + SOP & Risk +
+ +
diff --git a/src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.html b/src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.html new file mode 100644 index 000000000..bd260a185 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.html @@ -0,0 +1,22 @@ + + + + {{ label }} + + + + diff --git a/src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.scss b/src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.scss new file mode 100644 index 000000000..af63ad4ff --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.scss @@ -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%; +} diff --git a/src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.ts b/src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.ts new file mode 100644 index 000000000..e2969c53d --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-date-field/i4c-date-field.component.ts @@ -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(); + + 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): 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())}`; +} diff --git a/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.html b/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.html index 3d5775d81..eea31c44f 100644 --- a/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.html +++ b/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.html @@ -9,75 +9,144 @@
@if (missing.length) {
- - Still needed: - @for (m of missing; track m; let last = $last) { - {{ label(m) }}{{ last ? '' : ', ' }} - } +
+ + Mandatory Documents Required (MHA SOP): +
+
+ @for (m of missing; track m) { + {{ label(m) }} + } +
} - - - - - - - - - - - - @for (d of rows; track d.id) { +
+
TypeFileRemarksUploaded
+ - - - - - + + + + + - } @empty { - - - - } - -
{{ label(d.doc_type) }} - {{ d.file_name }} -
- {{ size(d.size_bytes) }} · {{ d.sha256.slice(0, 12) }}… -
-
{{ d.remarks || '-' }} - {{ d.uploaded_at | date: 'dd MMM yyyy, HH:mm' }} -
{{ d.uploaded_by }}
-
- - Document TypeFile DetailsRemarksUploaded Info
No documents attached yet.
+ + + @for (d of rows; track d.id) { + + + {{ label(d.doc_type) }} + + +
+ {{ d.file_name }} +
+ {{ size(d.size_bytes) }} + {{ d.sha256.slice(0, 10) }}… +
+
+ + + {{ d.remarks || '-' }} + + +
+ {{ d.uploaded_at | date: 'dd MMM yyyy, HH:mm' }} + by {{ d.uploaded_by }} +
+ + + + + + } @empty { + + +
+
+ +
+
No documents attached yet
+
+ Upload police intimations, IO notices, court orders (freeze/unfreeze), or grievance evidence below. +
+
+ + + } + + +
@if (canUpload) { -
- - Document type - - @for (t of types; track t.value) { - {{ t.label }} +
+
+
+ + Attach Case Document +
+ Secure Vault +
+ +
+
+ + Document Type + + @for (t of types; track t.value) { + {{ t.label }} + } + + + + + Remarks / Reference (Order no., Court, Date) + + +
+ +
+ + + @if (!file) { + + } @else { +
+ + {{ file.name }} + ({{ size(file.size) }}) + +
} - - - - Remarks (order no., date…) - - - - - PDF, JPEG or PNG, up to 5 MB. Stored with a SHA-256; never changed once attached. + + +
+ + +
}
diff --git a/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.scss b/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.scss index 03de95f89..38dcf7919 100644 --- a/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.scss +++ b/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.scss @@ -17,50 +17,420 @@ .docs { display: flex; flex-direction: column; - gap: 10px; + gap: 12px; + /* Alert banner for required documents */ &__missing { - padding: 8px 12px; + padding: 9px 12px; border-radius: 8px; border: 1px solid #fde68a; background: #fffbeb; color: #92400e; - font-size: 12.5px; display: flex; - flex-wrap: wrap; - gap: 4px; + 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; + flex-wrap: wrap; + 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; + 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 { font-weight: 600; + color: #0f172a; + font-size: 11.5px; overflow-wrap: anywhere; } &__sub { - font-size: 11px; + font-size: 10.5px; color: #64748b; + display: inline-flex; + align-items: center; + gap: 6px; } - &__upload { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 10px; - padding-top: 10px; - border-top: 1px solid #f1f5f9; + .doc-size { + font-weight: 500; + } - mat-form-field { - width: 200px; + .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; } } - &__remarks { - flex: 1 1 220px; + .doc-remarks { + color: #334155; + font-size: 11px; + max-width: 220px; + overflow-wrap: anywhere; } - &__file input { - font-size: 12px; - max-width: 240px; + .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; + } + + .file-remove-btn { + border: none; + background: transparent; + color: #dc2626; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.15s ease; + + &:hover { + background: rgb(220 38 38 / 12%); + } + } + + .doc-submit-btn { + 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; + } + + &:hover:not(:disabled) { + background: #1d4ed8; + } + } + + &__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; + } } } diff --git a/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.ts b/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.ts index 8b33a1ca9..ac1703022 100644 --- a/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.ts +++ b/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.ts @@ -116,6 +116,11 @@ export class I4cDocumentsComponent implements OnChanges { this.file = f; } + clearFile(input: HTMLInputElement): void { + this.file = null; + input.value = ''; + } + upload(input: HTMLInputElement): void { if (!this.file || !this.docType) { this.snackBar.open('Choose the document type and the file.', 'Close', { duration: 4000 }); diff --git a/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.html b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.html index f2aea1d43..cb16cc3e2 100644 --- a/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.html +++ b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.html @@ -327,10 +327,10 @@ } - - Order / decision date - - + } + @if (ioSilent(g)) { +
+
The IO has not decided within 15 days
+
SOP 9.1: the grievance goes to the District Grievance Officer.
+
+ +
+
+ } + @if (g.status === 'REJECTED' && g.stage !== 'CLOSED') {
Rejected by the {{ g.decision_level }}: {{ g.decision_remarks }}
diff --git a/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.scss b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.scss index 6779e507a..9cbccf145 100644 --- a/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.scss +++ b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.scss @@ -11,3 +11,7 @@ @include i4c-skin; @include i4c-case-page; + +.err-text { + color: #dc2626; +} diff --git a/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.ts b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.ts index a26e63460..1766b5796 100644 --- a/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.ts +++ b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.ts @@ -20,6 +20,7 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatSnackBar } from '@angular/material/snack-bar'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { I4cService, i4cErrorMessage } from '../i4c.service'; +import { I4cDateFieldComponent } from '../i4c-date-field/i4c-date-field.component'; import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.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_AND_REMOVE_HOLD', label: 'Unfreeze and remove put 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: 'SEEK_INFO', label: 'Seek additional information' } ]; @@ -69,7 +71,8 @@ const REQUESTS: Record = { MatPaginatorModule, FaIconComponent, I4cDateRangeComponent, - I4cDocumentsComponent + I4cDocumentsComponent, + I4cDateFieldComponent ] }) 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[] { return (g.requiredDocuments || []).filter((t: string) => !(g.documents || []).some((d: any) => d.doc_type === t)); } diff --git a/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.html b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.html index 6d16f8d71..49679561a 100644 --- a/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.html +++ b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.html @@ -226,7 +226,8 @@ Overview & Compliance @if ((h.status === 'ACTIVE' && h.amount_held > 0) || h.intimationLog?.length) { } @@ -248,12 +249,27 @@ + @if (h.actions?.length) { }
@@ -565,10 +581,7 @@ />
- - Order Date - - + Issuing Authority (IO, Court, DGRO...) @@ -600,138 +613,916 @@ @if (modalTab === 'cases') { -
-
-
- GRM grievance (customer contests the hold) +
+ +
+ +
+
+
+ +
+ GRM Grievance +
Customer contests the hold (MHA SOP 9.1)
+
+
+ @if (openGrievance) { + Open Contest + } @else if ((h.grievances || []).length) { + Logged ({{ h.grievances?.length }}) + } @else { + No Grievance + } +
+
+ @for (g of h.grievances || []; track g.id) { +
+
+ {{ g.grievance_no || 'Draft #' + g.id }} + {{ g.stage }} + {{ g.status }} +
+ +
+ } + + @if (h.status === 'ACTIVE' && !openGrievance) { +
+
+ + Open Customer Grievance +
+
+ + Customer asks to + + Remove the hold + Unfreeze the account + Restore digital services + + + + Customer's reason / grounds + + +
+ +
+ } @else if (!h.grievances?.length) { +
+ + No contestation received. Customer can contest the hold under MHA SOP 9.1(ii). +
+ } +
-
- @for (g of h.grievances || []; track g.id) { -
- {{ g.grievance_no || 'Draft #' + g.id }} - {{ g.stage }} + + +
+
+
+ +
+ MRM Money Restoration +
Restitution to genuine victims
+
+
+ @if (openRestoration) { + In Progress + } @else if ((h.restorations || []).length) { + Logged ({{ h.restorations?.length }}) + } @else { + No Restoration + } +
+
+ @for (r of h.restorations || []; track r.id) { +
+
+ {{ r.mrm_request_no || 'Restoration #' + r.id }} + {{ r.status }} + @if (r.restorable_amount) { + ₹{{ r.restorable_amount | number: '1.2-2' }} + } +
+ +
+ } + + @if (h.status === 'ACTIVE' && !openRestoration) { +
+
+ + Assess for Money Restoration +
+
+ + Restoration Basis + + @for (b of mrmBases; track b.value) { + {{ b.label }} + } + + + @if (assessForm.basis === 'ATTRIBUTION' || assessForm.basis === 'PRO_RATA') { + + Restorable amount (₹, max {{ h.amount_held | number: '1.2-2' }}) + + + } + + Officer remarks / justification + + +
+ +
+ } @else if (!h.restorations?.length) { +
+ + No restitution assessed yet. Victim claims are processed under MHA SOP 9.1(iv). +
+ } +
+
+
+ + +
+
+
+ +
+ Account Statement for Investigation +
+ Official statement required for GRM / MRM verification and LEA requests +
+
+
+ CSV Export +
+
+
+ + + +
+
+
+ + +
+
+
+ +
+ Legal & Regulatory Documents +
+ IO notices, court orders (freeze/unfreeze), FIR copies, and customer affidavits +
+
+
+ + {{ h.documents?.length || 0 }} Attached + +
+
+ +
+
+
+ } + + + @if (modalTab === 'risk') { +
+ + @if (h.linkedAlerts?.length || (h.reportsOnAccount || 0) > 1) { +
+
+ +
+
+
+ Mandatory MHA SOP & Risk Attention Required + Compliance Priority +
+
+ @if ((h.reportsOnAccount || 0) > 1) { + Account flagged in {{ h.reportsOnAccount }} complaints (suspected mule pattern). + + } + @if (h.linkedAlerts?.length) { + {{ h.linkedAlerts.length }} internal transfer alert(s) requiring action. + + } + Review mule registration status, fraud reporting (STR/FMR), and LEA 90-day review timelines + below. +
+
+
+ } + + +
+ +
+ +
+
+
+ + 90-Day Lapse (MHA SOP) +
{{ g.status }} - {{ g.created_at | date: 'dd MMM yyyy' }} + {{ isPast(h.lapse_on) ? 'Lapsed' : 'In Review' }} +
- } - @if (h.status === 'ACTIVE' && !openGrievance) { -
- - Customer asks to - - Remove the hold - Unfreeze the account - Restore digital services - - - - Customer's reason - - -
- - } @else if (!h.grievances?.length) { -
No grievance on this hold.
- } -
-
+
+ +
+
+ Review due + {{ + h.review_due_on ? (h.review_due_on | date: 'dd MMM yyyy') : '-' + }} +
+
+ Lapses on + + {{ h.lapse_on ? (h.lapse_on | date: 'dd MMM yyyy') : 'Counted from a GRM grievance' }} + +
+
+ Tell LEA by + + {{ h.lea_notice_due_on ? (h.lea_notice_due_on | date: 'dd MMM yyyy') : '-' }} + @if (h.lea_notice_sent_on) { + Sent {{ h.lea_notice_sent_on | date: 'dd MMM' }} + } + +
+
+ LEA Extension + + @if (h.extension_until) { + Until {{ h.extension_until | date: 'dd MMM yyyy' }} + } @else { + None + } + +
+
+ Release without order + + + {{ h.canLapseRelease ? 'Allowed now (90-day)' : 'Not yet' }} + + +
+
-
-
MRM money restoration
-
- @for (r of h.restorations || []; track r.id) { -
- {{ r.mrm_request_no || 'Restoration #' + r.id }} - {{ r.status }} - {{ r.basis }} - {{ r.restorable_amount ? '₹' + (r.restorable_amount | number: '1.2-2') : '' }} -
- } - @if (h.status === 'ACTIVE' && !openRestoration) { -
- - Restoration - - @for (b of mrmBases; track b.value) { - {{ b.label }} - } - - - @if (assessForm.basis === 'ATTRIBUTION' || assessForm.basis === 'PRO_RATA') { - - Restorable amount (₹, at most {{ h.amount_held | number: '1.2-2' }}) - - + @if (h.status === 'ACTIVE') { + +
+
+ Record Notice to LEA +
+
+ + + Notice reference + + +
+ +
+ + +
+
Record LEA Extension
+
+ + + Order reference + + +
+ +
} - - Remarks - -
- - } @else if (!h.restorations?.length) { -
No restoration on this hold.
+
+ + +
+
+
+ + Fraud Reporting (RBI / FIU-IND) +
+ + {{ (h.fraudReports || []).length ? 'Case Logged' : 'Pending' }} + +
+
+ @for (r of h.fraudReports || []; track r.id) { + @if (r.status === 'SHOW_CAUSE') { +
+ +
+ UCB Fraud Directions 2026: Show-cause notice required (21 days for response). STR + to FIU-IND due by {{ r.str_due_on | date: 'dd MMM yyyy' }}. +
+
+ @if (!r.show_cause_sent_on) { +
+
Issue Show-Cause Notice
+
+ +
+ +
+ } @else { +
+ Sent: {{ r.show_cause_sent_on | date: 'dd MMM yyyy' }} + Reply due: {{ r.show_cause_due_on | date: 'dd MMM yyyy' }} + @if (r.show_cause_responded_on) { + Replied {{ r.show_cause_responded_on | date: 'dd MMM' }} + } +
+ @if (!r.show_cause_responded_on) { +
+
Record Customer Reply
+
+ + + Reply summary + + +
+ +
+ } +
+
Final Classification Order
+
+ + Order reference + + + +
+
+ + +
+
+ } + } + +
+
+ Classified + + {{ r.classified_on ? (r.classified_on | date: 'dd MMM yyyy') : 'Not yet' }} • + {{ r.status }} + +
+
+ Police / LEA + {{ r.police_ref || 'Not yet' }} + {{ + r.police_reported_on ? '· ' + (r.police_reported_on | date: 'dd MMM yyyy') : '' + }} +
+
+ RBI FMR + + {{ + r.fmr_ref + ? r.fmr_ref + ' · filed ' + (r.fmr_filed_on | date: 'dd MMM yyyy') + : 'Due ' + (r.fmr_due_on | date: 'dd MMM yyyy') + }} + +
+
+ FIU-IND STR + + {{ + r.str_ref + ? r.str_ref + ' · filed ' + (r.str_filed_on | date: 'dd MMM yyyy') + : 'Due ' + (r.str_due_on | date: 'dd MMM yyyy') + }} + +
+
+ + @if ((r.status === 'OPEN' || r.status === 'SHOW_CAUSE') && fraudForm['id'] !== r.id) { + + } + } + + @if (fraudForm['id']) { +
+
Update Regulatory Filings
+
+ + Police / LEA ref + + + + + FMR ref + + + + + STR ref + + + +
+ +
+ } + + @if (!(h.fraudReports || []).length) { +
+
+ No fraud case initiated yet. Opening one starts the mandatory STR (7 days) and + show-cause notice (21 days) workflows. +
+ +
+ } +
+
+
+ + +
+ +
+
+
+ + Mule-Account Register +
+ + {{ (h.reportsOnAccount || 0) > 1 ? 'Mule Flagged' : 'Standard' }} + +
+
+
+ + + In {{ h.reportsOnAccount || 1 }} I4C complaint(s) • Flags automatically at ≥ + 2 + +
+ + @for (f of h.muleFlags || []; track f.id) { +
+
+ + {{ f.status }} + + {{ f.source }} + {{ f.flagged_at | date: 'dd MMM yyyy' }} +
+
{{ f.reason }}
+ @if (f.review_remarks) { +
Remarks: {{ f.review_remarks }}
+ } +
+ + @if (f.status === 'SUSPECTED') { +
+
Review Mule Flag
+
+ + Outcome + + Confirmed mule + Not a mule (clear) + + + + Basis / Evidence + + +
+ +
+ } + } + + @if (!(h.muleFlags || []).length) { +
+
Flag Account as Suspected Mule
+
+ + Source + + Officer + LEA + + + + Reason + + +
+ +
+ } +
+
+ + +
+
+
+ + Money Moved Inside Bank +
+ + {{ (h.linkedAlerts || []).length ? h.linkedAlerts.length + ' Alert' : 'Clean' }} + +
+
+ @for (a of h.linkedAlerts || []; track a.id) { +
+
+ + {{ a.kind === 'INTERNAL_TRANSFER' ? (a.target_type | titlecase) + ' account' : 'Loan' }} + + {{ a.target_no }} + ₹{{ a.amount | number: '1.2-2' }} + {{ a.status }} +
+
+ Moved on {{ a.moved_on | date: 'dd MMM yyyy' }} + @if (a.remarks) { + • {{ a.remarks }} + } +
+ + @if (a.status === 'OPEN') { +
+
Resolve Transfer Alert
+ + Action taken / remarks + + +
+ + +
+
+ } +
+ } @empty { +
+ No internal transfers or loan repayments detected from + this account. +
+ } +
+
+ + +
+
+
+ + Digital Channels +
+ + {{ h.digital_suspended ? 'Suspended' : 'Operational' }} + +
+
+ @for (b of h.channelBlocks || []; track b.id) { +
+ {{ b.status }} + {{ b.channels }} +
+ Placed {{ b.placed_at | date: 'dd MMM yyyy' }} by {{ b.placed_by + }}{{ b.lifted_at ? ' · lifted ' + (b.lifted_at | date: 'dd MMM') : '' }} +
+
+ } @empty { +
+ +
+ All digital channels active (UPI, IMPS, NEFT, RTGS, ATM, cards). +
+ To suspend or restore access, submit a request under Request Action (Maker). +
+
+
+ } +
+
+ + + @if (h.excludedKind?.length) { +
+
+
+ + Nodal / Escrow Account +
+
+
+
+ Listed as a {{ h.excludedKind[0].kind | lowercase }} account: disputed amount held, but + no prospective hold on future credits and no suspension (MHA SOP 9.1(iii)). +
+
+
+ } + + + @if (repaidLoan(h) && h.relatedAccounts?.length) { +
+
+
+ + Suspect's Other Accounts (CFCFRMS) +
+
+
+
+ Fraud money repaid a loan here; notify suspect's other accounts on CFCFRMS (MHA SOP 9.1(v)). +
+ @for (r of h.relatedAccounts; track r.account_no) { +
+ {{ r.account_type | titlecase }} {{ r.account_no }} + ₹{{ r.balance | number: '1.2-2' }} +
+ } +
+
}
- -
-
- Bank statement (GRM / MRM ask for it) -
-
-
- - From (default 90 days back) - - - - To (default today) - - -
- -
-
- -
-
- Documents on this hold (IO / court orders...) -
-
- -
-
} diff --git a/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.scss b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.scss index 618708fea..1fd0fde56 100644 --- a/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.scss +++ b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.scss @@ -200,37 +200,809 @@ max-width: 320px; } -.cases-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 14px; -} +/* ======================================================== + SHARED PRIMITIVES FOR I4C CASES & RISK TABS + ======================================================== */ +.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) { - .cases-grid { - grid-template-columns: minmax(0, 1fr); + &--blue { + background: #eff6ff; + 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; - flex-wrap: wrap; align-items: center; gap: 8px; - padding: 8px 10px; - margin-bottom: 8px; - border: 1px solid #e2e8f0; - border-radius: 8px; - cursor: pointer; + flex-wrap: wrap; +} - &:hover, - &:focus-visible { - border-color: #2563eb; - outline: none; +/* ======================================================== + 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; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover, + &:focus-visible { + border-color: #2563eb; + background: #eff6ff; + 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 { font-size: 11px; color: #64748b; + line-height: 1.45; } } diff --git a/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.ts b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.ts index b6f1be221..38eb38677 100644 --- a/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.ts +++ b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.ts @@ -7,7 +7,7 @@ */ 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 { ActivatedRoute, Router, RouterLink } from '@angular/router'; 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 { FaIconComponent } from '@fortawesome/angular-fontawesome'; 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 { MRM_BASES } from '../i4c-restorations/i4c-restorations.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: [ DatePipe, DecimalPipe, + LowerCasePipe, + TitleCasePipe, FormsModule, RouterLink, MatButtonModule, @@ -47,7 +50,8 @@ import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-rang MatPaginatorModule, I4cDateRangeComponent, I4cDocumentsComponent, - FaIconComponent + FaIconComponent, + I4cDateFieldComponent ] }) export class I4cHoldsComponent implements OnInit { @@ -96,7 +100,14 @@ export class I4cHoldsComponent implements OnInit { loading = false; busy = false; 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 = {}; + alertRemarks: Record = {}; + showCause = { sentOn: '', respondedOn: '', response: '', orderRef: '', orderOn: '' }; readonly mrmBases = MRM_BASES; grievanceForm = { customerRequest: 'REMOVE_HOLD', customerReason: '' }; assessForm = { basis: 'ATTRIBUTION', restorableAmount: null as number | null, remarks: '' }; @@ -228,16 +239,19 @@ export class I4cHoldsComponent implements OnInit { return []; } const list = [{ value: 'RELEASE', label: 'Release hold' }]; - if (h.account_type === 'SAVINGS') { - list.push( - h.debit_frozen - ? { value: 'UNFREEZE', label: 'Unfreeze debits, hold continues' } - : { value: 'FREEZE', label: 'Freeze all debits (on explicit LEA / court direction only)' } - ); - } + list.push( + h.debit_frozen + ? { value: 'UNFREEZE', label: 'Unfreeze debits, hold continues' } + : { value: 'FREEZE', label: 'Freeze all debits (on explicit LEA / court direction only)' } + ); if (h.debit_frozen) { list.push({ value: 'UNFREEZE_AND_RELEASE', label: 'Unfreeze and remove the hold' }); } + 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; } @@ -307,6 +321,90 @@ export class I4cHoldsComponent implements OnInit { }); } + /** POST on /i4c/cases and reload the hold. */ + private caseAction(path: string, body: Record, 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): 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 { this.router.navigate(['/cfcfrms/i4c/' + page], { queryParams: { open: id } }); } diff --git a/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.html b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.html index b15be4f0e..f488235fa 100644 --- a/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.html +++ b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.html @@ -75,6 +75,16 @@ RESPONSE only + + Layer + + All layers + L0 · Victim + L1 · First layer + L2 + L3 + + Created At Complaint Ack No. + Layer Type Delivery Status Attempts @@ -121,6 +132,12 @@ {{ o.acknowledgement_no }} + + {{ layerLabel(o.layer) }} + {{ o.message_type === 'ACK' ? 'ACK' : 'RESPONSE' @@ -158,7 +175,7 @@ } @empty { - +
No responses in the outbox queue matching current filter.
@@ -235,6 +252,16 @@ placeholder="e.g. 00, 11" /> + + Layer + + All layers + L0 · Victim + L1 · First layer + L2 + L3 + + HTTP Status Response Code Ack No. + Layer Latency (ms) Caller IP Error Details @@ -290,6 +318,16 @@ {{ l.acknowledgement_no || '-' }} + + @if (l.layer !== null && l.layer !== undefined) { + {{ layerLabel(l.layer) }} + } @else { + - + } + {{ l.duration_ms }} ms {{ l.client_ip || '-' }} @@ -298,7 +336,7 @@ } @empty { - +
No API calls recorded.
diff --git a/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.scss b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.scss index c79acb2f9..796aa3608 100644 --- a/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.scss +++ b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.scss @@ -189,3 +189,30 @@ color: #64748b; 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; + } +} diff --git a/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.ts b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.ts index 69bf63e85..a421cc8ec 100644 --- a/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.ts +++ b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.ts @@ -66,6 +66,7 @@ export class I4cOutboxComponent implements OnInit { outboxStatus = ''; outboxType = ''; + outboxLayer = ''; outboxSearch = ''; outboxDates: I4cDateRange = { ...ALL_DATES }; outboxTotal = 0; @@ -78,6 +79,7 @@ export class I4cOutboxComponent implements OnInit { logSearch = ''; logHttpStatus = ''; logResponseCode = ''; + logLayer = ''; logDates: I4cDateRange = { ...ALL_DATES }; logTotal = 0; logPageIndex = 0; @@ -123,12 +125,19 @@ export class I4cOutboxComponent implements OnInit { } 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 { this.outboxStatus = ''; this.outboxType = ''; + this.outboxLayer = ''; this.outboxSearch = ''; this.outboxDates = { ...ALL_DATES }; this.loadOutbox(true); @@ -152,6 +161,7 @@ export class I4cOutboxComponent implements OnInit { this.logSearch || this.logHttpStatus || this.logResponseCode || + this.logLayer || this.logDates.preset !== 'ALL' ); } @@ -162,6 +172,7 @@ export class I4cOutboxComponent implements OnInit { this.logSearch = ''; this.logHttpStatus = ''; this.logResponseCode = ''; + this.logLayer = ''; this.logDates = { ...ALL_DATES }; this.loadLog(true); } @@ -177,6 +188,7 @@ export class I4cOutboxComponent implements OnInit { .outbox({ status: this.outboxStatus, type: this.outboxType, + layer: this.outboxLayer, search: this.outboxSearch.trim(), fromDate: this.outboxDates.fromDate, toDate: this.outboxDates.toDate, @@ -207,6 +219,7 @@ export class I4cOutboxComponent implements OnInit { search: this.logSearch.trim(), httpStatus: this.logHttpStatus, responseCode: this.logResponseCode.trim(), + layer: this.logLayer, fromDate: this.logDates.fromDate, toDate: this.logDates.toDate, 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 { return status !== null && status !== undefined && status >= 200 && status < 300; } diff --git a/src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.ts b/src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.ts index 8ac0ee4f2..90ffe565b 100644 --- a/src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.ts +++ b/src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.ts @@ -130,6 +130,7 @@ export class I4cRequestDetailComponent implements OnInit { return 'i4c-badge--green'; case 'PARTIALLY_HELD': case 'PROSPECTIVE_HOLD': + case 'NOT_HELD_MOVED_ON': return 'i4c-badge--amber'; case 'NOT_FOUND': case 'NO_MATCH': diff --git a/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.html b/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.html index 345ee675e..6b74ec863 100644 --- a/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.html +++ b/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.html @@ -205,15 +205,16 @@ [class.modal-tab--active]="modalTab === 'incidents'" (click)="modalTab = 'incidents'" > - Incidents ({{ - selectedComplaint.incidents?.length || selectedComplaint.incidents || 0 - }}) + Incidents + {{ selectedComplaint.incidents?.length || selectedComplaint.incidents || 0 }}
} @else if (action.action === 'NOT_COMPLIED' || action.action === 'REFER_BACK_LEA') {
@@ -462,13 +489,12 @@ @if (r.status === 'IN_POOL') {
- ₹{{ 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
- It left the suspect account on approval. The outward {{ r.transfer_channel }} to - {{ r.victim_account_no }} / {{ r.victim_ifsc }} has not gone through yet{{ - r.transfer_error ? ': ' + r.transfer_error : '.' - }} + It left the suspect account on approval (Dr suspect, Cr I4C Restoration Payable). The outward + {{ r.transfer_channel }} to {{ r.victim_account_no }} / {{ r.victim_ifsc }} has not gone through + yet{{ r.transfer_error ? ': ' + r.transfer_error : '.' }}
} + + @if ((r.status === 'RESTORED' || r.status === 'CLOSED') && r.restored_at) { +
+
+ Suspect's challenge (SOP: within 15 days of the refund, until + {{ r.challenge_until | date: 'dd MMM yyyy' }}) +
+ @if (r.contested) { +
+ 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. +
+ } @else { +
+ + + Details + + +
+
+ +
+ } +
+ } } @else { @if (r.status !== 'CLOSED') { +
+
+ Customer's claim on the bank (RBI customer protection in fraudulent EBTs) +
+ @if (r.liability_category) { +
+ {{ + r.liability_regime === 'RBI_2026' + ? 'RBI 2026 directions (RBI/2026-27/172)' + : 'RBI 2017 framework' + }} + · {{ 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' : '' }} +
+
{{ r.liability_note }}
+
+ @if (r.shadow_credit_due_on) { + Shadow reversal due + {{ r.shadow_credit_due_on | date: 'dd MMM yyyy' }} + · + } + Resolve by + {{ + r.resolution_due_on | date: 'dd MMM yyyy' + }} + ({{ + r.liability_regime === 'RBI_2026' + ? (r.cross_border ? '60' : '45') + ' calendar days' + : '90 days' + }}) +
+ } +
+ + + + + Caused by + + @for (c of liabilityCategories; track c.value) { + {{ c.label }} + } + + + + Gross loss (₹) + + + + Recovered so far (₹) + + + Cross-border +
+
+ +
+ + @if (r.liability_category === 'CUSTOMER_NEGLIGENCE') { +
+ Compensation (RBI 2026, para 65.1T): + @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 }} + } +
+ @if (r.comp_eligible && !r.comp_status) { + + } @else if (r.comp_status === 'APPLIED') { +
+ Applied {{ r.comp_applied_on | date: 'dd MMM yyyy' }}; pay by + {{ r.comp_due_on | date: 'dd MMM yyyy' }}. +
+
+ + +
+ } @else if (r.comp_status) { +
+ Compensation {{ r.comp_status }} {{ r.comp_paid_on | date: 'dd MMM yyyy' }} +
+ } + } + + @if (r.liability_category && r.liability_category !== 'CUSTOMER_NEGLIGENCE') { + @if (!r.shadow_credit_status || r.shadow_credit_status === 'REJECTED') { +
+ + Shadow reversal (₹) + + + +
+
+ A provisional credit the customer cannot use until the case is decided (a lien is placed on + it). +
+ } @else if (r.shadow_credit_status === 'PENDING') { +
+ Shadow reversal of ₹{{ r.shadow_credit_amount | number: '1.2-2' }} requested by + {{ r.shadow_credit_requested_by }}; a different officer approves it. +
+
+ + +
+ } @else { +
+ 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' }} + } +
+
+ @if (!r.shadow_finalised_at && !r.regularised_at) { + + } + @if (!r.regularised_at && r.received_amount) { + + } +
+ } + } +
Restored money received for our customer
@@ -516,10 +748,10 @@ UTR / transaction id * - - Credited on * - - + Credited to account diff --git a/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.ts b/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.ts index f60029726..e6e433575 100644 --- a/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.ts +++ b/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.ts @@ -20,6 +20,7 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatSnackBar } from '@angular/material/snack-bar'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { I4cService, i4cErrorMessage } from '../i4c.service'; +import { I4cDateFieldComponent } from '../i4c-date-field/i4c-date-field.component'; import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.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: 'REQUESTED', label: 'Victim request received' }, { 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: 'RESTORED', label: 'Restored' }, { value: 'AWAITED', label: 'Victim bank: awaited' }, @@ -89,7 +90,8 @@ const VICTIM_STATUSES = [ MatPaginatorModule, FaIconComponent, I4cDateRangeComponent, - I4cDocumentsComponent + I4cDocumentsComponent, + I4cDateFieldComponent ] }) export class I4cRestorationsComponent implements OnInit { @@ -112,6 +114,8 @@ export class I4cRestorationsComponent implements OnInit { role = 'BENEFICIARY_BANK'; status = 'OPEN'; bankAction = ''; + overdueOnly = false; + contestedOnly = false; search = ''; dates: I4cDateRange = { ...ALL_DATES }; rows: any[] = []; @@ -151,6 +155,8 @@ export class I4cRestorationsComponent implements OnInit { role: this.role, status: this.status, bankAction: this.bankAction, + overdue: this.overdueOnly || null, + contested: this.contestedOnly || null, search: this.search.trim(), fromDate: this.dates.fromDate, toDate: this.dates.toDate, @@ -187,12 +193,21 @@ export class I4cRestorationsComponent implements OnInit { } 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 { this.status = 'OPEN'; this.bankAction = ''; + this.overdueOnly = false; + this.contestedOnly = false; this.search = ''; this.dates = { ...ALL_DATES }; this.load(true); @@ -242,6 +257,7 @@ export class I4cRestorationsComponent implements OnInit { courtName: r.court_name || '', bnssNoticeNo: r.bnss_notice_no || '', bnssNoticeDate: r.bnss_notice_date || '', + noticeReceivedOn: r.notice_received_on || '', indemnityBondReceived: !!r.indemnity_bond_received }; 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 { this.i4c.proRata(this.selected.hold_account_no).subscribe({ next: (p) => { diff --git a/src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.html b/src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.html new file mode 100644 index 000000000..faba4c4e1 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.html @@ -0,0 +1,616 @@ + + +
+
+
+ @for (t of tabs; track t.value) { + + } +
+ + @if (tab === 'fraud-reports') { + + } + {{ total }} records +
+ +
+ + Account / customer / reference + + + + Status + + Any + @for (s of statuses; track s) { + {{ s }} + } + + + @if (tab === 'fraud-reports') { + FMR / STR overdue + } + @if (tab === 'bank-holds') { + Past a deadline + + } + + +
+ + @if (loading) { + + } + @if (error) { +
+ {{ error }} +
+ } + + @if (tab === 'exclusions') { +
+
+
+ + Account no. + + + + Kind + + Nodal account + Pool account + Escrow account + + + + What it is (merchant nodal, aggregator pool...) + + +
+ +
+ 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. +
+
+
+ } + @if (tab === 'bank-holds' && showPlace) { +
+
Hold a suspected mule transaction
+
+
+ + Account no. + + + + Hold + + The flagged transaction's amount + All debits on the account (last resort) + + + + Why flagged + + Does not match the customer's profile + Linked to an account reported for fraud + Other + + + + Transaction ref + + + + Amount (₹, at least 1,000) + + + + Reason (given to the customer) + + +
+ +
+ 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. +
+
+
+ } + +
+
+ + @switch (tab) { + @case ('mule-flags') { + Mule-account register + Accounts in two or more complaints are flagged automatically; review each with EDD or the LEA. + } + @case ('fraud-reports') { + Fraud reporting + Police / LEA, RBI fraud monitoring return (14 days), FIU-IND STR (7 days) from classification. + } + @case ('linked-alerts') { + Fraud money that moved on inside this bank + 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. + } + @case ('exclusions') { + Nodal / pool / escrow accounts + Disputed amount is held; no prospective hold on future credits and normally no suspension (MHA SOP + 9.1(iii)). + } + @case ('bank-holds') { + Bank-initiated debit holds + Suspected mule transactions the bank holds itself (RBI draft mule SOP). + } + @case ('channel-blocks') { + Digital-channel suspensions + Placed and lifted through hold requests (maker-checker). CBS fund transfers refuse them. + } + } +
+
+ + + @switch (tab) { + @case ('mule-flags') { + + + + + + + + + + } + @case ('fraud-reports') { + + + + + + + + + + + } + @case ('linked-alerts') { + + + + + + + + + + } + @case ('exclusions') { + + + + + + + + + } + @case ('bank-holds') { + + + + + + + + + + } + @case ('channel-blocks') { + + + + + + + + + } + } + + + @for (r of rows; track r.id) { + @switch (tab) { + @case ('mule-flags') { + + + + + + + + + + } + @case ('fraud-reports') { + + + + + + + + + + + } + @case ('linked-alerts') { + + + + + + + + + + } + @case ('exclusions') { + + + + + + + + + } + @case ('bank-holds') { + + + + + + + + + + } + @case ('channel-blocks') { + + + + + + + + + } + } + @if (editing?.id === r.id && tab === 'bank-holds') { + + + + } + @if (editing?.id === r.id && tab !== 'bank-holds') { + + + + } + } @empty { + + + + } + +
FlaggedAccount / CustomerSourceComplaintsReasonStatus
ClassifiedAccount / CustomerAmount (₹)Police / LEARBI FMRFIU-IND STRStatus
Moved onFrom (held account)Moved toAmount (₹)StatusRemarks
AddedAccountKindWhat it isStatus
PlacedAccount / CustomerHoldStatusNext dateCustomer told
PlacedAccountChannelsReasonStatusLifted
+ {{ r.flagged_at | date: 'dd MMM yyyy' }} +
{{ r.flagged_by }}
+
+ {{ r.account_no }} +
{{ r.client_name }} · {{ r.account_type }}
+
{{ r.source }}{{ r.reports_count }} + {{ r.reason }} +
{{ r.review_remarks }}
+
+ {{ r.status }} + + @if (r.status === 'SUSPECTED') { + + } +
+ {{ r.classified_on ? (r.classified_on | date: 'dd MMM yyyy') : 'Show-cause' }} + @if (r.show_cause_due_on && !r.classified_on) { +
reply due {{ r.show_cause_due_on | date: 'dd MMM' }}
+ } +
+ {{ r.account_no }} +
{{ r.client_name }} · {{ r.acknowledgement_nos }}
+
{{ r.amount | number: '1.2-2' }} + {{ r.police_ref || '-' }} +
{{ r.police_reported_on | date: 'dd MMM yyyy' }}
+
+ {{ r.fmr_ref || 'due ' + (r.fmr_due_on | date: 'dd MMM') }} +
{{ r.fmr_filed_on | date: 'dd MMM yyyy' }}
+
+ {{ r.str_ref || 'due ' + (r.str_due_on | date: 'dd MMM') }} +
{{ r.str_filed_on | date: 'dd MMM yyyy' }}
+
+ {{ r.status }} + + @if (r.status === 'OPEN') { + + } +
{{ r.moved_on | date: 'dd MMM yyyy' }} + {{ r.hold_account_no }} +
Ack {{ r.acknowledgement_no }}
+
+ {{ r.target_no }} +
+ {{ r.kind === 'INTERNAL_TRANSFER' ? r.target_type + ' account here' : 'Loan repayment' }} +
+
{{ r.amount | number: '1.2-2' }} + {{ r.status }} + {{ r.remarks || '-' }} + @if (r.status === 'OPEN') { + + } +
+ {{ r.added_at | date: 'dd MMM yyyy' }} +
{{ r.added_by }}
+
+ {{ r.account_no }} +
{{ r.account_type }}
+
{{ r.kind }}{{ r.reason }} + {{ r.status }} + + @if (r.status === 'ACTIVE') { + + } +
+ {{ r.placed_at | date: 'dd MMM yyyy' }} +
{{ r.placed_by }}
+
+ {{ r.account_no }} +
{{ r.client_name }} · {{ r.trigger_type }}
+
+ {{ r.scope === 'ACCOUNT' ? 'All debits' : '₹' + (r.hold_amount | number: '1.2-2') }} +
+ txn {{ r.flagged_txn_ref || '-' }} · ₹{{ r.flagged_amount | number: '1.2-2' }} +
+
+ {{ r.status }} + @if (r.lift_reason) { +
{{ r.lift_reason }}
+ } +
+ @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) { +
grievance due {{ r.grievance_due_on | date: 'dd MMM' }}
+ } +
+ {{ + r.customer_notified_at + ? (r.customer_notified_at | date: 'dd MMM') + ' · ' + r.notification_mode + : 'Not yet' + }} + + @if (r.status !== 'LIFTED' || (r.grievance_raised_on && !r.grievance_resolved_on)) { + + } +
+ {{ r.placed_at | date: 'dd MMM yyyy' }} +
{{ r.placed_by }}
+
+ {{ r.account_no }} +
{{ r.account_type }}
+
{{ r.channels }}{{ r.reason }} + {{ r.status }} + {{ r.lifted_at ? (r.lifted_at | date: 'dd MMM yyyy') + ' · ' + r.lifted_by : '-' }}
+
+ + Step + + @for (b of bankSteps; track b.value) { + {{ b.label }} + } + + + @switch (step) { + @case ('notice') { + + How (PHONE, LETTER...) + + + + Reference + + + } + @case ('explanation') { + + + Explanation + + + } + @case ('decision') { + + Decision + + Satisfied: lift + Not satisfied: refer to police + + + @if (form['decision'] === 'REFER') { + + NCRP reference + + + } + + Basis + + + } + @case ('police') { + + Instruction + + Continue the hold + Lift the hold + + + + Police reference + + + } + @case ('grievance') { + + Customer's grievance (resolve within 30 days) + + + } + @case ('grievance-resolution') { + + Resolution + + + } + } +
+
+ + +
+
+
+ @if (tab === 'fraud-reports') { + + Police / LEA ref + + + + + FMR ref + + + + + STR ref + + + + } @else { + + Outcome + + @if (tab === 'mule-flags') { + Confirmed mule + Not a mule (clear) + } @else { + Actioned + Dismissed + } + + + } + + Remarks + + +
+
+ + +
+
Nothing here for these filters.
+
+ +
+
diff --git a/src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.scss b/src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.scss new file mode 100644 index 000000000..e297f2fec --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.scss @@ -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; +} diff --git a/src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.ts b/src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.ts new file mode 100644 index 000000000..f36c246d2 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-risk/i4c-risk.component.ts @@ -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 = {}; + + 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, 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 }) + }); + } +} diff --git a/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.scss b/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.scss index 3878f045f..6c03296de 100644 --- a/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.scss +++ b/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.scss @@ -124,23 +124,23 @@ border: 1px solid #e2e8f0; border-radius: 12px; 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 { display: flex; - flex-wrap: nowrap; + flex-wrap: wrap; align-items: center; - gap: 4px; + gap: 2px; width: 100%; } &__link { display: inline-flex; align-items: center; - gap: 6px; - padding: 6px 12px; + gap: 5px; + padding: 5px 9px; border-radius: 8px; - font-size: 11.5px; + font-size: 11px; font-weight: 600; color: #475569; text-decoration: none; @@ -162,7 +162,8 @@ } } - &--active { + &--active, + &--active:hover { background: #2563eb; color: #fff !important; box-shadow: 0 3px 10px rgb(37 99 235 / 28%); diff --git a/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.ts b/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.ts index 3920727dd..1868049da 100644 --- a/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.ts +++ b/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.ts @@ -43,6 +43,7 @@ export class I4cShellComponent implements OnInit { { path: 'grievances', label: 'GRM Grievances', icon: 'balance-scale' }, { path: 'restorations', label: 'MRM Restorations', icon: 'hand-holding-usd' }, { path: 'mrm-report', label: 'MRM Balance Report', icon: 'file-csv' }, + { path: 'risk', label: 'Risk Registers', icon: 'user-shield' }, { path: 'outbox', label: 'Responses & API Log', icon: 'paper-plane' }, { path: 'bank-master', label: 'Bank Code Master', icon: 'university' }, { path: 'intimation-settings', label: 'Intimation Settings', icon: 'cog' } diff --git a/src/app/cfcfrms/i4c/i4c.service.ts b/src/app/cfcfrms/i4c/i4c.service.ts index 400d6f518..126c2f1c3 100644 --- a/src/app/cfcfrms/i4c/i4c.service.ts +++ b/src/app/cfcfrms/i4c/i4c.service.ts @@ -50,6 +50,21 @@ export interface I4cHold { documents?: I4cDocument[]; released_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; } @@ -141,7 +156,7 @@ export interface I4cPage { export type I4cListParams = Record; 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. */ amount?: number | null; reasonType: string; @@ -259,6 +274,19 @@ export class I4cService { return this.http.get>(`${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) ────────────────────────────────────────── grievances(query: I4cListParams = {}): Observable> { @@ -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 = {}): Observable { + return this.http.post(`${this.cases}/${path}`, body); + } + + casePut(path: string, body: Record = {}): Observable { + return this.http.put(`${this.cases}/${path}`, body); + } + + /** A paged register on /i4c/cases: mule-flags, fraud-reports, linked-alerts, channel-blocks. */ + register(name: string, query: I4cListParams = {}): Observable> { + return this.http.get>(`${this.cases}/${name}`, { params: this.params(query) }); + } + + fraudReportsCsv(): Observable { + return this.http.get(`${this.cases}/fraud-reports.csv`, { responseType: 'blob' }); + } + documents(ownerType: I4cDocumentOwner, ownerId: number): Observable { return this.http.get(`${this.cases}/documents`, { params: this.params({ ownerType, ownerId }) }); }