From fca550f4c434cd0a5362ce67e53c649c5a2c585c Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 23 Oct 2026 11:43:30 +0530 Subject: [PATCH] feat(cfcfrms): I4C Live screens for the I4C-MHA integration New CFCFRMS > I4C Live section backed by /v1/i4c/admin and /v1/i4c/cases (the demo mock interceptor does not match these paths): - Dashboard, complaints from I4C, holds register, approvals, responses and API log, bank code master, intimation settings. - Hold popup: overview, hold transactions, customer intimation (automatic and manual), maker request (full / partial release, freeze, unfreeze), GRM / MRM and documents, request history. - GRM grievances, MRM restorations and MRM balance report pages; shared date range filter (today, yesterday, week, month, custom), server-side paging and filters on every list; document upload / download. Co-Authored-By: Claude Opus 5.5 --- src/app/cfcfrms/cfcfrms-routing.module.ts | 73 ++ .../cfcfrms-shell.component.html | 11 + src/app/cfcfrms/i4c/_i4c-cases.scss | 144 ++++ src/app/cfcfrms/i4c/_i4c.scss | 789 +++++++++++++++++ .../i4c-approvals.component.html | 343 ++++++++ .../i4c-approvals.component.scss | 244 ++++++ .../i4c-approvals/i4c-approvals.component.ts | 231 +++++ .../i4c-bank-master.component.html | 258 ++++++ .../i4c-bank-master.component.scss | 360 ++++++++ .../i4c-bank-master.component.ts | 167 ++++ .../i4c-dashboard.component.html | 484 +++++++++++ .../i4c-dashboard.component.scss | 499 +++++++++++ .../i4c-dashboard/i4c-dashboard.component.ts | 126 +++ .../i4c-date-range.component.html | 49 ++ .../i4c-date-range.component.scss | 95 +++ .../i4c-date-range.component.ts | 102 +++ .../i4c-documents.component.html | 83 ++ .../i4c-documents.component.scss | 66 ++ .../i4c-documents/i4c-documents.component.ts | 174 ++++ .../i4c-grievances.component.html | 558 ++++++++++++ .../i4c-grievances.component.scss | 13 + .../i4c-grievances.component.ts | 359 ++++++++ .../i4c/i4c-holds/i4c-holds.component.html | 794 ++++++++++++++++++ .../i4c/i4c-holds/i4c-holds.component.scss | 236 ++++++ .../i4c/i4c-holds/i4c-holds.component.ts | 455 ++++++++++ .../i4c-intimation-settings.component.html | 148 ++++ .../i4c-intimation-settings.component.scss | 172 ++++ .../i4c-intimation-settings.component.ts | 185 ++++ .../i4c-mrm-report.component.html | 149 ++++ .../i4c-mrm-report.component.scss | 13 + .../i4c-mrm-report.component.ts | 123 +++ .../i4c/i4c-outbox/i4c-outbox.component.html | 497 +++++++++++ .../i4c/i4c-outbox/i4c-outbox.component.scss | 191 +++++ .../i4c/i4c-outbox/i4c-outbox.component.ts | 306 +++++++ .../i4c-request-detail.component.html | 334 ++++++++ .../i4c-request-detail.component.scss | 69 ++ .../i4c-request-detail.component.ts | 166 ++++ .../i4c-requests/i4c-requests.component.html | 566 +++++++++++++ .../i4c-requests/i4c-requests.component.scss | 180 ++++ .../i4c-requests/i4c-requests.component.ts | 320 +++++++ .../i4c-restorations.component.html | 682 +++++++++++++++ .../i4c-restorations.component.scss | 21 + .../i4c-restorations.component.ts | 378 +++++++++ .../i4c/i4c-shell/i4c-shell.component.html | 215 +++++ .../i4c/i4c-shell/i4c-shell.component.scss | 473 +++++++++++ .../i4c/i4c-shell/i4c-shell.component.ts | 141 ++++ src/app/cfcfrms/i4c/i4c.service.ts | 384 +++++++++ 47 files changed, 12426 insertions(+) create mode 100644 src/app/cfcfrms/i4c/_i4c-cases.scss create mode 100644 src/app/cfcfrms/i4c/_i4c.scss create mode 100644 src/app/cfcfrms/i4c/i4c-approvals/i4c-approvals.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-approvals/i4c-approvals.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-approvals/i4c-approvals.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-bank-master/i4c-bank-master.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.html create mode 100644 src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.scss create mode 100644 src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.ts create mode 100644 src/app/cfcfrms/i4c/i4c.service.ts diff --git a/src/app/cfcfrms/cfcfrms-routing.module.ts b/src/app/cfcfrms/cfcfrms-routing.module.ts index f9cb5d33b..16db64820 100644 --- a/src/app/cfcfrms/cfcfrms-routing.module.ts +++ b/src/app/cfcfrms/cfcfrms-routing.module.ts @@ -19,6 +19,18 @@ import { ReportMuleComponent } from './report-mule/report-mule.component'; import { ReportFraudTxnComponent } from './report-fraud-txn/report-fraud-txn.component'; import { FmrSubmissionComponent } from './fmr-submission/fmr-submission.component'; import { CfrLookupComponent } from './cfr-lookup/cfr-lookup.component'; +import { I4cShellComponent } from './i4c/i4c-shell/i4c-shell.component'; +import { I4cDashboardComponent } from './i4c/i4c-dashboard/i4c-dashboard.component'; +import { I4cRequestsComponent } from './i4c/i4c-requests/i4c-requests.component'; +import { I4cRequestDetailComponent } from './i4c/i4c-request-detail/i4c-request-detail.component'; +import { I4cHoldsComponent } from './i4c/i4c-holds/i4c-holds.component'; +import { I4cApprovalsComponent } from './i4c/i4c-approvals/i4c-approvals.component'; +import { I4cOutboxComponent } from './i4c/i4c-outbox/i4c-outbox.component'; +import { I4cBankMasterComponent } from './i4c/i4c-bank-master/i4c-bank-master.component'; +import { I4cIntimationSettingsComponent } from './i4c/i4c-intimation-settings/i4c-intimation-settings.component'; +import { I4cGrievancesComponent } from './i4c/i4c-grievances/i4c-grievances.component'; +import { I4cRestorationsComponent } from './i4c/i4c-restorations/i4c-restorations.component'; +import { I4cMrmReportComponent } from './i4c/i4c-mrm-report/i4c-mrm-report.component'; const routes: Routes = [ Route.withShell([ @@ -76,6 +88,67 @@ const routes: Routes = [ path: 'cfr-lookup', component: CfrLookupComponent, data: { title: 'CFR Lookup', breadcrumb: 'CFR Lookup' } + }, + { + // Live I4C-MHA integration (NCRP-CFCFRMS API). Served by /v1/i4c/admin on the CBS; the demo mock + // interceptor does not match these paths. + path: 'i4c', + component: I4cShellComponent, + data: { title: 'I4C Live Integration', breadcrumb: 'I4C Live' }, + children: [ + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { + path: 'dashboard', + component: I4cDashboardComponent, + data: { title: 'I4C Dashboard', breadcrumb: 'Dashboard' } + }, + { + path: 'requests', + component: I4cRequestsComponent, + data: { title: 'Complaints from I4C', breadcrumb: 'Complaints' } + }, + { + path: 'requests/:id', + component: I4cRequestDetailComponent, + data: { title: 'I4C Complaint', breadcrumb: 'Complaint' } + }, + { path: 'holds', component: I4cHoldsComponent, data: { title: 'I4C Holds Register', breadcrumb: 'Holds' } }, + { + path: 'approvals', + component: I4cApprovalsComponent, + data: { title: 'I4C Approvals', breadcrumb: 'Approvals' } + }, + { + path: 'outbox', + component: I4cOutboxComponent, + data: { title: 'I4C Responses', breadcrumb: 'Responses' } + }, + { + path: 'bank-master', + component: I4cBankMasterComponent, + data: { title: 'I4C Bank Code Master', breadcrumb: 'Bank Master' } + }, + { + path: 'grievances', + component: I4cGrievancesComponent, + data: { title: 'I4C GRM Grievances', breadcrumb: 'GRM Grievances' } + }, + { + path: 'restorations', + component: I4cRestorationsComponent, + data: { title: 'I4C MRM Restorations', breadcrumb: 'MRM Restorations' } + }, + { + path: 'mrm-report', + component: I4cMrmReportComponent, + data: { title: 'I4C MRM Balance Report', breadcrumb: 'MRM Balance Report' } + }, + { + path: 'intimation-settings', + component: I4cIntimationSettingsComponent, + data: { title: 'I4C Intimation Settings', breadcrumb: 'Intimation Settings' } + } + ] } ] } diff --git a/src/app/cfcfrms/cfcfrms-shell/cfcfrms-shell.component.html b/src/app/cfcfrms/cfcfrms-shell/cfcfrms-shell.component.html index d9fce160f..bd1e26fb5 100644 --- a/src/app/cfcfrms/cfcfrms-shell/cfcfrms-shell.component.html +++ b/src/app/cfcfrms/cfcfrms-shell/cfcfrms-shell.component.html @@ -34,6 +34,17 @@ Dashboard + + + I4C Live +
+ + +
+ +
+
+ + API Gateway Performance (Last 30 Days) + RBI Review Periodic Spec +
+
+ + + + + + + + + + + + + + @for (row of data.apiPerformance30d; track row.direction) { + + + + + + + + + + } @empty { + + + + } + +
DirectionCallsSucceededFailedSuccess RateAvg (ms)Max (ms)
+ + {{ row.direction === 'IN' ? '↓ Inbound (I4C → Bank)' : '↑ Outbound (Bank → I4C)' }} + + {{ row.calls }}{{ row.succeeded }}{{ row.failed }} + @if (successRate(row) === null) { + - + } @else { +
+
+
+
+ {{ successRate(row) | number: '1.0-1' }}% +
+ } +
{{ row.avg_ms ? row.avg_ms + ' ms' : '-' }}{{ row.max_ms ? row.max_ms + ' ms' : '-' }}
No API calls recorded in the last 30 days.
+
+ +
+ + +
+
+ + Complaints by Status +
+
+ @for (row of data.requestsByStatus; track row.status) { + +
+ {{ row.status }} + View complaints +
+ {{ row.count }} +
+ } @empty { +
No complaints received yet.
+ } +
+
+
+ + +
+ +
+
+ + Findings Reported to I4C (Section 6) +
+
+ + + + + + + + + + @for (row of data.findingsByType; track row.txn_type) { + + + + + + } @empty { + + + + } + +
Transaction / Hold TypeEntriesReported Amount
+ {{ row.txn_type }} + {{ row.count }}₹{{ row.amount | number: '1.2-2' }}
No findings reported to I4C yet.
+
+
+ + +
+
+ + Live Gateway Configuration + +
+
+
+
Gateway Status
+
+ + {{ data.configuration.enabled ? 'ENABLED' : 'DISABLED' }} + +
+
+ +
+
Tenant
+
{{ data.configuration.tenant }}
+
+ +
+
Bank Code (I4C root)
+
+ {{ data.configuration.bankCode || 'Not configured' }} +
+
+ +
+
Encryption Mode
+
+ {{ data.configuration.cryptoMode }} +
+
+ +
+
I4C Outbound API Base URL
+
+ {{ data.configuration.outboundBaseUrl || 'Not configured' }} + @if (data.configuration.outboundBaseUrl) { + + } +
+
+ +
+
Review Window
+
{{ data.configuration.holdReviewDays }} Days (90-day cycle)
+
+ +
+
Match Window
+
± {{ data.configuration.matchDateWindowDays }} Days
+
+
+
+
+ } + + + @if (showConfigModal && data?.configuration) { +
+
+
+
+ +
+ I4C-MHA Integration Configuration +
+ NCRP-CFCFRMS Specification 2.0 Security & Connection Parameters +
+
+
+ +
+ +
+
+
Integration Enabled
+
+ + {{ data.configuration.enabled ? 'ACTIVE (Online)' : 'DISABLED' }} + +
+ +
Tenant ID
+
{{ data.configuration.tenant }}
+ +
Bank Code (I4C ID)
+
+ {{ data.configuration.bankCode }} +
+ +
Inbound Credentials
+
+ {{ data.configuration.inboundCredentialsConfigured ? 'Configured' : 'Not configured' }} + @if (data.configuration.inboundPasswordHashed) { + bcrypt hash active + } +
+ +
Allowed I4C IPs
+
{{ data.configuration.allowedIpCount || 'Any / All (UAT mode)' }}
+ +
Payload Encryption
+
+ {{ data.configuration.cryptoMode }} + @if (data.configuration.cryptoPassphraseConfigured) { + passphrase active + } +
+ +
Outbound Base URL
+
{{ data.configuration.outboundBaseUrl || 'Not set' }}
+ +
Automatic Hold Review
+
{{ data.configuration.holdReviewDays }} days
+ +
Transaction Match Window
+
± {{ data.configuration.matchDateWindowDays }} days
+ +
Simulator Status
+
+ + {{ data.configuration.simulatorEnabled ? 'SIMULATOR ACTIVE (UAT)' : 'PRODUCTION' }} + +
+
+ +
+ + All passwords, encryption keys, and tokens are stored in the server keystore and never revealed on the + UI. +
+
+ + +
+
+ } +
diff --git a/src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.scss b/src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.scss new file mode 100644 index 000000000..12a1e3cdb --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.scss @@ -0,0 +1,499 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use '../i4c' as *; + +@include i4c-skin; + +/* Setup Banner */ +.setup-banner { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 10px 14px; + border: 1px solid #fde68a; + background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); + border-radius: 10px; + box-shadow: 0 4px 10px rgb(245 158 11 / 8%); + + &__icon { + font-size: 18px; + color: #d97706; + margin-top: 1px; + } + + &__title { + font-weight: 700; + color: #92400e; + font-size: 12.5px; + } + + &__list { + margin: 4px 0 0; + padding-left: 18px; + color: #92400e; + font-size: 11.5px; + line-height: 1.5; + } +} + +/* KPI Groups */ +.kpi-groups { + display: grid; + grid-template-columns: 2fr 3fr 4fr; + gap: 12px; +} + +@media (width <= 1280px) { + .kpi-groups { + grid-template-columns: 1fr 1fr; + + .kpi-group--action { + grid-column: 1 / -1; + } + } +} + +@media (width <= 768px) { + .kpi-groups { + grid-template-columns: 1fr; + } +} + +.kpi-group { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + + &__head { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #64748b; + + fa-icon { + color: #2563eb; + } + } + + &__row { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: 8px; + } +} + +@media (width <= 580px) { + .kpi-group__row { + grid-auto-flow: row; + grid-template-columns: 1fr; + } +} + +/* Modern Fintech KPI Tile */ +.modern-kpi { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 14px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + text-decoration: none; + color: inherit; + box-shadow: 0 3px 10px rgb(15 23 42 / 3%); + transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); + position: relative; + overflow: hidden; + + &:hover { + transform: translateY(-2px); + border-color: #93c5fd; + box-shadow: 0 8px 16px -3px rgb(37 99 235 / 12%); + } + + .kpi-top { + display: flex; + align-items: center; + justify-content: space-between; + } + + .kpi-icon-circle { + width: 28px; + height: 28px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + + &--blue { + background: #eff6ff; + color: #2563eb; + } + + &--green { + background: #ecfdf5; + color: #059669; + } + + &--amber { + background: #fffbeb; + color: #d97706; + } + + &--red { + background: #fef2f2; + color: #dc2626; + } + + &--purple { + background: #f5f3ff; + color: #7c3aed; + } + } + + .kpi-trend { + font-size: 9.5px; + font-weight: 700; + text-transform: uppercase; + padding: 1px 6px; + border-radius: 9999px; + background: #f1f5f9; + color: #64748b; + + &--green { + background: #ecfdf5; + color: #059669; + } + + &--warn { + background: #fffbeb; + color: #d97706; + } + + &--alert { + background: #fef2f2; + color: #dc2626; + } + } + + &__value { + font-size: 18px; + font-weight: 800; + color: #0f172a; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + } + + &__label { + font-size: 11px; + font-weight: 600; + color: #334155; + } + + &__sub { + font-size: 10px; + color: #94a3b8; + } + + &--warn { + border-color: #fde68a; + background: #fffdf5; + + .modern-kpi__value { + color: #b45309; + } + } + + &--alert { + border-color: #fecaca; + background: #fffbfb; + + .modern-kpi__value { + color: #b91c1c; + } + } +} + +/* Dashboard Grid */ +.dashboard-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 18px; + + &--equal { + grid-template-columns: 1fr 1fr; + } +} + +@media (width <= 1024px) { + .dashboard-grid, + .dashboard-grid--equal { + grid-template-columns: 1fr; + } +} + +/* API Performance */ +.rbi-badge { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + color: #1e40af; + background: #dbeafe; + padding: 3px 10px; + border-radius: 9999px; +} + +.direction-chip { + display: inline-block; + padding: 3px 9px; + border-radius: 6px; + font-size: 11px; + font-weight: 700; + + &--in { + background: #eff6ff; + color: #1d4ed8; + } + + &--out { + background: #f0fdf4; + color: #15803d; + } +} + +.success-num { + color: #059669; + font-weight: 700; +} + +.fail-num { + color: #dc2626; + font-weight: 700; +} + +.modern-rate { + display: inline-flex; + align-items: center; + gap: 8px; + justify-content: flex-end; + + &__bar { + width: 60px; + height: 7px; + background: #fee2e2; + border-radius: 9999px; + overflow: hidden; + } + + &__fill { + display: block; + height: 100%; + background: linear-gradient(90deg, #10b981, #059669); + border-radius: 9999px; + } + + &__text { + font-size: 12px; + font-weight: 700; + font-variant-numeric: tabular-nums; + } +} + +.latency-pill { + color: #64748b; + font-size: 12px; +} + +.card-footer-info { + padding: 12px 20px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: #475569; + + .footer-stat { + display: flex; + align-items: center; + gap: 8px; + + fa-icon { + color: #3b82f6; + } + } + + .stat-highlight { + color: #0f172a; + font-weight: 700; + } + + .footer-note { + font-size: 11px; + color: #94a3b8; + } +} + +/* Status Tile List */ +.status-grid { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; +} + +.status-tile { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid #f1f5f9; + background: #f8fafc; + text-decoration: none; + color: inherit; + transition: all 0.2s; + + &:hover { + background: #fff; + border-color: #cbd5e1; + box-shadow: 0 4px 10px rgb(15 23 42 / 5%); + transform: translateY(-1px); + } + + &__left { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__hint { + font-size: 11px; + color: #94a3b8; + } + + &__count { + font-size: 18px; + font-weight: 800; + color: #0f172a; + font-variant-numeric: tabular-nums; + } +} + +/* Configuration Grid */ +.config-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + padding: 16px 20px; +} + +.config-tile { + display: flex; + flex-direction: column; + gap: 4px; + + &--full { + grid-column: 1 / -1; + } + + &__label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #94a3b8; + } + + &__value { + font-size: 13px; + color: #0f172a; + font-weight: 500; + } + + .url-val { + display: flex; + align-items: center; + gap: 8px; + font-family: monospace; + font-size: 12px; + word-break: break-all; + } +} + +.config-popup-trigger { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 8px; + background: #f1f5f9; + border: 1px solid #cbd5e1; + color: #2563eb; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #e2e8f0; + color: #1d4ed8; + } +} + +.crypto-chip { + padding: 2px 8px; + border-radius: 6px; + background: #eff6ff; + color: #1e40af; + font-size: 11px; + font-weight: 700; +} + +.copy-btn { + padding: 2px 8px; + border-radius: 6px; + background: #2563eb; + color: #fff; + border: none; + font-size: 10px; + font-weight: 600; + cursor: pointer; + + &:hover { + background: #1d4ed8; + } +} + +.badge-mini { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + background: #f1f5f9; + font-size: 11px; + color: #64748b; + margin-left: 6px; +} + +.highlight-amt { + color: #0f172a; + font-weight: 700; +} diff --git a/src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.ts b/src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.ts new file mode 100644 index 000000000..87ebde7da --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-dashboard/i4c-dashboard.component.ts @@ -0,0 +1,126 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { DecimalPipe } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { I4cService, i4cErrorMessage } from '../i4c.service'; + +/** + * Integration health at a glance, and the API performance figures the RBI letter (05 Dec 2025, para 4) asks to be + * reviewed periodically: calls, failures and response times in each direction. + */ +@Component({ + selector: 'mifosx-i4c-dashboard', + templateUrl: './i4c-dashboard.component.html', + styleUrls: ['./i4c-dashboard.component.scss'], + imports: [ + DecimalPipe, + RouterLink, + MatButtonModule, + MatProgressBarModule, + FaIconComponent + ] +}) +export class I4cDashboardComponent implements OnInit { + private i4c = inject(I4cService); + + data: any = null; + loading = false; + error = ''; + showConfigModal = false; + copiedKey = ''; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.error = ''; + this.i4c.dashboard().subscribe({ + next: (d) => { + this.data = d; + this.loading = false; + }, + error: (e) => { + this.error = i4cErrorMessage(e); + this.loading = false; + } + }); + } + + copyText(text: string, key: string): void { + if (!text) { + return; + } + navigator.clipboard.writeText(text); + this.copiedKey = key; + setTimeout(() => { + if (this.copiedKey === key) { + this.copiedKey = ''; + } + }, 2000); + } + + statusClass(status: string): string { + switch (status) { + case 'PROCESSED': + return 'i4c-badge--green'; + case 'RETRY': + return 'i4c-badge--red'; + case 'RECEIVED': + case 'PROCESSING': + return 'i4c-badge--amber'; + default: + return ''; + } + } + + /** Success rate of calls in one direction, as a percentage; null when there were none. */ + successRate(row: any): number | null { + const calls = Number(row?.calls || 0); + return calls === 0 ? null : (Number(row?.succeeded || 0) * 100) / calls; + } + + /** What still stands between this bank and live traffic, worked out from the configuration. */ + get setupGaps(): string[] { + const c = this.data?.configuration; + if (!c) { + return []; + } + const gaps: string[] = []; + if (!c.enabled) { + gaps.push('Integration is switched off (fineract.i4c.enabled=false): I4C calls are refused with 503.'); + } + if (!c.inboundCredentialsConfigured) { + gaps.push('No inbound username/password set for I4C to obtain a token.'); + } else if (!c.inboundPasswordHashed) { + gaps.push('Inbound password is configured in plain text; set the bcrypt hash for production.'); + } + if (!c.allowedIpCount) { + gaps.push('No I4C source IP allowlist: any address may call (UAT only).'); + } + if (c.cryptoMode !== 'NONE' && !c.cryptoPassphraseConfigured) { + gaps.push('Encryption mode is ' + c.cryptoMode + ' but no passphrase is configured.'); + } + if (!c.outboundConfigured) { + gaps.push('I4C response URL is not configured: replies wait in the outbox.'); + } + if (!c.bankCode) { + gaps.push("This bank's I4C bank code is not set (fineract.i4c.bank-code); root_bankid is sent as 0."); + } + if (c.simulatorEnabled) { + gaps.push('The local I4C simulator is enabled; switch it off in production.'); + } + return gaps; + } +} diff --git a/src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.html b/src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.html new file mode 100644 index 000000000..e2c0f0964 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.html @@ -0,0 +1,49 @@ + + +
+ {{ label }} +
+ @for (p of presets; track p.value) { + + } +
+ @if (range.preset !== 'ALL') { +
+ + +
+ } +
diff --git a/src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.scss b/src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.scss new file mode 100644 index 000000000..e6f1c59f1 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.scss @@ -0,0 +1,95 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +:host { + display: block; +} + +.date-range { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + + &__label { + font-size: 12px; + font-weight: 600; + color: #475569; + display: inline-flex; + align-items: center; + gap: 6px; + } + + &__presets { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; + padding: 3px; + border-radius: 999px; + background: #f1f5f9; + } + + &__custom { + display: inline-flex; + flex-wrap: wrap; + gap: 8px; + + label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #475569; + } + + input { + height: 32px; + padding: 0 8px; + border: 1px solid #cbd5e1; + border-radius: 8px; + font: inherit; + font-size: 13px; + color: #0f172a; + background: #fff; + + &:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 1px; + } + } + } +} + +.date-chip { + border: 0; + background: transparent; + border-radius: 999px; + padding: 4px 12px; + font-size: 12px; + font-weight: 600; + color: #475569; + cursor: pointer; + + &:hover { + color: #1d4ed8; + } + + &:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 1px; + } + + &--active { + background: #2563eb; + color: #fff; + + &:hover { + color: #fff; + } + } +} diff --git a/src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.ts b/src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.ts new file mode 100644 index 000000000..3d3740e16 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-date-range/i4c-date-range.component.ts @@ -0,0 +1,102 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; + +export type I4cDatePreset = 'ALL' | 'TODAY' | 'YESTERDAY' | 'WEEK' | 'MONTH' | 'CUSTOM'; + +/** A date range as the list APIs take it: yyyy-MM-dd, both days included, empty for "no limit". */ +export interface I4cDateRange { + preset: I4cDatePreset; + fromDate: string; + toDate: string; +} + +export const ALL_DATES: I4cDateRange = { preset: 'ALL', fromDate: '', toDate: '' }; + +/** yyyy-MM-dd in the browser's own time zone (toISOString would shift to UTC). */ +function iso(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +/** The days a preset stands for, worked out from today. */ +export function presetRange(preset: I4cDatePreset): I4cDateRange { + const today = new Date(); + const day = (offset: number) => new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset); + switch (preset) { + case 'TODAY': + return { preset, fromDate: iso(today), toDate: iso(today) }; + case 'YESTERDAY': + return { preset, fromDate: iso(day(-1)), toDate: iso(day(-1)) }; + case 'WEEK': { + // The week starts on Monday. + const sinceMonday = (today.getDay() + 6) % 7; + return { preset, fromDate: iso(day(-sinceMonday)), toDate: iso(today) }; + } + case 'MONTH': + return { preset, fromDate: iso(new Date(today.getFullYear(), today.getMonth(), 1)), toDate: iso(today) }; + default: + return { ...ALL_DATES }; + } +} + +/** + * Date filter shared by the I4C lists: All, Today, Yesterday, This Week, This Month, or a custom From / To. Emits the + * new range whenever it changes; the page then reloads from page one. + */ +@Component({ + selector: 'mifosx-i4c-date-range', + templateUrl: './i4c-date-range.component.html', + styleUrls: ['./i4c-date-range.component.scss'], + imports: [ + FormsModule, + FaIconComponent + ] +}) +export class I4cDateRangeComponent { + @Input() label = 'Date'; + @Input() range: I4cDateRange = { ...ALL_DATES }; + @Output() rangeChange = new EventEmitter(); + + readonly presets: { value: I4cDatePreset; label: string }[] = [ + { value: 'ALL', label: 'All' }, + { value: 'TODAY', label: 'Today' }, + { value: 'YESTERDAY', label: 'Yesterday' }, + { value: 'WEEK', label: 'This Week' }, + { value: 'MONTH', label: 'This Month' }, + { value: 'CUSTOM', label: 'Custom' } + ]; + + readonly today = iso(new Date()); + + pick(preset: I4cDatePreset): void { + if (preset === 'CUSTOM') { + // Keep the days already shown so the officer can adjust them. + this.range = { ...this.range, preset }; + return; + } + this.emit(presetRange(preset)); + } + + customChanged(): void { + const { fromDate, toDate } = this.range; + if (fromDate && toDate && toDate < fromDate) { + // A reversed range would match nothing; read it the way it was meant. + this.range = { preset: 'CUSTOM', fromDate: toDate, toDate: fromDate }; + } + this.emit({ ...this.range, preset: 'CUSTOM' }); + } + + private emit(range: I4cDateRange): void { + this.range = range; + this.rangeChange.emit(range); + } +} diff --git a/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.html b/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.html new file mode 100644 index 000000000..3d5775d81 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.html @@ -0,0 +1,83 @@ + + +
+ @if (missing.length) { +
+ + Still needed: + @for (m of missing; track m; let last = $last) { + {{ label(m) }}{{ last ? '' : ', ' }} + } +
+ } + + + + + + + + + + + + + @for (d of rows; track d.id) { + + + + + + + + } @empty { + + + + } + +
TypeFileRemarksUploaded
{{ 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 }}
+
+ +
No documents attached yet.
+ + @if (canUpload) { +
+ + Document type + + @for (t of types; track t.value) { + {{ t.label }} + } + + + + 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 new file mode 100644 index 000000000..03de95f89 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.scss @@ -0,0 +1,66 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use '../i4c' as *; + +@include i4c-skin; + +:host { + display: block; +} + +.docs { + display: flex; + flex-direction: column; + gap: 10px; + + &__missing { + padding: 8px 12px; + border-radius: 8px; + border: 1px solid #fde68a; + background: #fffbeb; + color: #92400e; + font-size: 12.5px; + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + } + + &__name { + font-weight: 600; + overflow-wrap: anywhere; + } + + &__sub { + font-size: 11px; + color: #64748b; + } + + &__upload { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding-top: 10px; + border-top: 1px solid #f1f5f9; + + mat-form-field { + width: 200px; + } + } + + &__remarks { + flex: 1 1 220px; + } + + &__file input { + font-size: 12px; + max-width: 240px; + } +} diff --git a/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.ts b/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.ts new file mode 100644 index 000000000..8b33a1ca9 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-documents/i4c-documents.component.ts @@ -0,0 +1,174 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, EventEmitter, Input, OnChanges, Output, inject } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { DOCUMENT_TYPES, I4cDocument, I4cDocumentOwner, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service'; + +const MAX_BYTES = 5 * 1024 * 1024; +const ACCEPTED = [ + 'application/pdf', + 'image/jpeg', + 'image/png' +]; + +/** + * Documents behind a hold, grievance or restoration: list, download, and upload (PDF / JPEG / PNG up to 5 MB). Shows + * which of the {@link required} types are still missing. + */ +@Component({ + selector: 'mifosx-i4c-documents', + templateUrl: './i4c-documents.component.html', + styleUrls: ['./i4c-documents.component.scss'], + imports: [ + DatePipe, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + FaIconComponent + ] +}) +export class I4cDocumentsComponent implements OnChanges { + private i4c = inject(I4cService); + private snackBar = inject(MatSnackBar); + + @Input({ required: true }) ownerType!: I4cDocumentOwner; + @Input({ required: true }) ownerId!: number; + /** Document types this record needs; missing ones are flagged. */ + @Input() required: string[] = []; + /** Types offered first in the picker. */ + @Input() suggested: string[] = []; + @Input() canUpload = true; + /** Documents already loaded with the record, to avoid a second call. */ + @Input() documents: I4cDocument[] | null = null; + @Output() changed = new EventEmitter(); + + rows: I4cDocument[] = []; + docType = ''; + remarks = ''; + file: File | null = null; + uploading = false; + + ngOnChanges(): void { + if (this.documents) { + this.rows = this.documents; + } else { + this.reload(); + } + if (!this.docType) { + this.docType = this.suggested[0] || this.missing[0] || 'OTHER'; + } + } + + get types(): { value: string; label: string }[] { + const first = DOCUMENT_TYPES.filter((t) => this.suggested.includes(t.value) || this.required.includes(t.value)); + return [ + ...first, + ...DOCUMENT_TYPES.filter((t) => !first.includes(t)) + ]; + } + + get missing(): string[] { + return this.required.filter((t) => !this.rows.some((d) => d.doc_type === t)); + } + + label(type: string): string { + return DOCUMENT_TYPES.find((t) => t.value === type)?.label || type; + } + + reload(): void { + if (!this.ownerId) { + return; + } + this.i4c.documents(this.ownerType, this.ownerId).subscribe({ + next: (rows) => (this.rows = rows), + error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }) + }); + } + + pick(event: Event): void { + const input = event.target as HTMLInputElement; + const f = input.files?.[0] || null; + if (f && !ACCEPTED.includes(f.type)) { + this.snackBar.open('Only PDF, JPEG and PNG files can be attached.', 'Close', { duration: 5000 }); + input.value = ''; + return; + } + if (f && f.size > MAX_BYTES) { + this.snackBar.open('A document can be at most 5 MB.', 'Close', { duration: 5000 }); + input.value = ''; + return; + } + this.file = f; + } + + upload(input: HTMLInputElement): void { + if (!this.file || !this.docType) { + this.snackBar.open('Choose the document type and the file.', 'Close', { duration: 4000 }); + return; + } + const file = this.file; + this.uploading = true; + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = String(reader.result || ''); + const content = dataUrl.substring(dataUrl.indexOf(',') + 1); + this.i4c + .uploadDocument({ + ownerType: this.ownerType, + ownerId: this.ownerId, + docType: this.docType, + fileName: file.name, + contentType: file.type, + content, + remarks: this.remarks.trim() || undefined + }) + .subscribe({ + next: () => { + this.uploading = false; + this.file = null; + this.remarks = ''; + input.value = ''; + this.snackBar.open(this.label(this.docType) + ' attached.', 'OK', { duration: 3000 }); + this.documents = null; + this.reload(); + this.changed.emit(); + }, + error: (e) => { + this.uploading = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + } + }); + }; + reader.onerror = () => { + this.uploading = false; + this.snackBar.open('The file could not be read.', 'Close', { duration: 5000 }); + }; + reader.readAsDataURL(file); + } + + download(d: I4cDocument): void { + this.i4c.downloadDocument(d.id).subscribe({ + next: (blob) => saveBlob(blob, d.file_name), + error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }) + }); + } + + size(bytes: number): string { + return bytes < 1024 * 1024 ? Math.ceil(bytes / 1024) + ' KB' : (bytes / 1024 / 1024).toFixed(1) + ' MB'; + } +} diff --git a/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.html b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.html new file mode 100644 index 000000000..f2aea1d43 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.html @@ -0,0 +1,558 @@ + + +
+
+
+ + @for (s of stages; track s.value) { + + } + +
+ + {{ total }} grievances +
+ +
+ + GR id / account / ack no. / customer + + + + Outcome + + Any + Pending decision + Allowed (order given) + Rejected + Left to the court + Withdrawn + + + Past deadline only + + +
+ + @if (loading) { + + } + @if (error) { +
+ {{ error }} +
+ } + +
+
+ + GRM Grievances (NCRP Grievance Redressal Module) + Raise a grievance from the hold (Holds Register → hold → GRM / MRM). Click a row to work on it. +
+
+ + + + + + + + + + + + + + + @for (g of rows; track g.id) { + + + + + + + + + + + } @empty { + + + + } + +
RaisedGR idAccount / CustomerCustomer asksHeld (₹)StageDeadlineOutcome
+ {{ g.created_at | date: 'dd MMM yyyy' }} +
{{ g.created_by }}
+
+ {{ g.grievance_no || 'Draft #' + g.id }} + + {{ g.account_no }} +
{{ g.client_name }} · Ack {{ g.acknowledgement_no }}
+
{{ requestLabel(g.customer_request) }}{{ g.amount_held | number: '1.2-2' }} + {{ stageLabel(g.stage) }} + + @if (g.compliance_due_at && g.stage !== 'CLOSED') { + + Carry out by {{ g.compliance_due_at | date: 'dd MMM, HH:mm' }} + + } @else if (g.due_at) { + {{ g.due_at | date: 'dd MMM yyyy' }} + } @else { + - + } + + {{ g.status }} + @if (g.decision) { +
{{ g.decision_level }}: {{ decisionLabel(g.decision) }}
+ } +
No grievances match these filters.
+
+ +
+ + @if (selected; as g) { +
+
+
+
+ +
+ +
+ Account {{ g.account_no }} ({{ g.client_name }}) · Ack {{ g.acknowledgement_no }} · Held ₹{{ + g.amount_held | number: '1.2-2' + }}{{ g.debit_frozen ? ' · debits frozen' : '' }} +
+
+
+
+ +
+
+ +
+ + + + +
+ +
+
+ @for (f of flow(g); track f.value; let last = $last) { + {{ f.label }} + @if (!last) { + › + } + } +
+ + @if (tab === 'workflow') { +
+
+ @if (g.stage === 'DRAFT') { + @if (!g.edd_done) { +
+
1. Enhanced due diligence (before raising on GRM)
+
+ KYC re-verified + + Risk + + Low + Medium + High + + + + Bank's view + + Supports the request + Does not support it + + +
+ + Source of the disputed credit, as the customer explains it * + + + + Customer's statement / notes + + +
+ +
+
+ } @else { +
+
2. Raise it on GRM, then record the GR id
+
+ GRM's entry form needs the KYC document, the customer's application, a photograph and the bank + statement. Attach them on the Documents tab (the statement can be downloaded from the hold). +
+ @if (missingDocs(g).length) { +
Still to attach: {{ missingDocs(g).join(', ') }}
+ } +
+ + GR id from GRM * + + + +
+
+ } + } + + @if (g.stage === 'NODAL_REVIEW') { +
+
Bank GRM nodal officer's decision
+ + Remarks (required to reject) + + +
+ + +
+
+ } + + @if (withOfficer && g.status === 'OPEN') { +
+
+ Decision of the {{ level }} + @if (g.due_at) { + + · due {{ g.due_at | date: 'dd MMM yyyy' }} + } +
+
+ + Decision on GRM + + @for (d of decisions; track d.value) { + {{ d.label }} + } + + + @if (decision.decision === 'PARTIAL_HOLD') { + + Amount to keep on hold (₹) + + + } + @if (decision.decision !== 'SEEK_INFO' && decision.decision !== 'REJECTED_CONTINUE') { + + Order no. * + + + } + + Order / decision date + + +
+ + Remarks{{ decision.decision === 'REJECTED_CONTINUE' ? ' (reason given) *' : '' }} + + + @if (decision.decision !== 'SEEK_INFO' && decision.decision !== 'REJECTED_CONTINUE') { +
+ Attach the signed and stamped order copy (GRM order or IO order) on the Documents tab first. +
+ } +
+ +
+
+ +
+
Something else happened on GRM
+
+ + Event + + @for (n of noteEvents; track n.value) { + {{ n.label }} + } + + + + Remarks + + +
+
+ + @if (g.stage === 'WITH_IO') { + + } +
+
+ } + + @if (g.status === 'REJECTED' && g.stage !== 'CLOSED') { +
+
Rejected by the {{ g.decision_level }}: {{ g.decision_remarks }}
+
+ If the customer is not satisfied, the branch raises a review: IO → District GRO → State GRO (15 + days each); after the State GRO the customer may approach the jurisdictional court. +
+ + Remarks + + +
+ +
+
+ } + + @if (g.status === 'ALLOWED' && g.stage !== 'CLOSED') { +
+
+ Order: {{ decisionLabel(g.decision) }} + @if (g.decision_keep_amount !== null) { + (keep ₹{{ g.decision_keep_amount | number: '1.2-2' }}) + } + · {{ g.decision_order_ref }} +
+
+ Carry it out on the account by + {{ + g.compliance_due_at | date: 'dd MMM yyyy, HH:mm' + }}. The change goes to a second officer for approval; the grievance closes when it is approved. +
+ @if (g.actionRequest?.length) { +
+ Request #{{ g.actionRequest[0].id }} ({{ g.actionRequest[0].action }}) is + {{ g.actionRequest[0].status }} + {{ g.actionRequest[0].failure_message || g.actionRequest[0].rejection_reason || '' }} +
+ } + @if ( + !g.actionRequest?.length || + g.actionRequest[0].status === 'REJECTED' || + g.actionRequest[0].status === 'FAILED' + ) { +
+ +
+ } +
+ } + + @if (g.stage === 'CLOSED') { +
This grievance is closed ({{ g.status }}).
+ } @else { +
+
+ +
+
+ } +
+
+ } + + @if (tab === 'overview') { +
+
+
Customer's request
+
+
+
Asks to
+
{{ requestLabel(g.customer_request) }}
+
Reason given
+
{{ g.customer_reason || '-' }}
+
Opened
+
{{ g.created_at | date: 'dd MMM yyyy, HH:mm' }} by {{ g.created_by }}
+
Decision
+
+ {{ g.decision ? g.decision_level + ': ' + decisionLabel(g.decision) : '-' }} + @if (g.decision_order_ref) { +
+ Order {{ g.decision_order_ref }} {{ g.decision_date | date: 'dd MMM yyyy' }} +
+ } +
+
+
+
+
+
Enhanced due diligence
+
+ @if (g.edd_done) { +
+
KYC re-verified
+
{{ g.edd_kyc_verified ? 'Yes' : 'No' }}
+
Source of funds
+
{{ g.edd_source_of_funds }}
+
Statement
+
{{ g.edd_customer_statement || '-' }}
+
Risk / view
+
+ {{ g.edd_risk }} · + {{ g.edd_recommendation === 'SUPPORT' ? 'Bank supports' : 'Bank does not support' }} +
+
By
+
{{ g.edd_by }}, {{ g.edd_at | date: 'dd MMM yyyy, HH:mm' }}
+
+ } @else { +
Not done yet.
+ } +
+
+
+ } + + @if (tab === 'documents') { +
+
+ +
+
+ } + + @if (tab === 'history') { +
+
+ + + + + + + + + + + + @for (e of g.events; track e.id) { + + + + + + + + } + +
WhenEventStageRemarksBy
{{ e.created_at | date: 'dd MMM yyyy, HH:mm' }} + {{ e.event_type }} + {{ stageLabel(e.stage) }}{{ e.remarks || '-' }}{{ e.actor }}
+
+
+ } +
+ + +
+
+ } +
diff --git a/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.scss b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.scss new file mode 100644 index 000000000..6779e507a --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.scss @@ -0,0 +1,13 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use '../i4c' as *; +@use '../i4c-cases' as *; + +@include i4c-skin; +@include i4c-case-page; diff --git a/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.ts b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.ts new file mode 100644 index 000000000..a26e63460 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-grievances/i4c-grievances.component.ts @@ -0,0 +1,359 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { DatePipe, DecimalPipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { I4cService, i4cErrorMessage } from '../i4c.service'; +import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component'; +import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component'; + +export const GRM_STAGES: { value: string; label: string }[] = [ + { value: 'DRAFT', label: 'Draft (EDD)' }, + { value: 'NODAL_REVIEW', label: 'Bank nodal review' }, + { value: 'WITH_IO', label: 'With IO (7 days)' }, + { value: 'IO_INVESTIGATION', label: 'IO investigating (15 days)' }, + { value: 'WITH_DGRO', label: 'District GRO review (15 days)' }, + { value: 'WITH_SGRO', label: 'State GRO review (15 days)' }, + { value: 'CLOSED', label: 'Closed' } +]; + +export const GRM_DECISIONS: { value: string; label: string }[] = [ + { value: 'REMOVE_HOLD', label: 'Remove put on hold' }, + { value: 'UNFREEZE_HOLD_CONTINUES', label: 'Unfreeze the account, hold continues' }, + { value: 'UNFREEZE_AND_REMOVE_HOLD', label: 'Unfreeze and remove put on hold' }, + { value: 'PARTIAL_HOLD', label: 'Keep a smaller amount on hold' }, + { value: 'REJECTED_CONTINUE', label: 'Request rejected, hold / freeze continues' }, + { value: 'SEEK_INFO', label: 'Seek additional information' } +]; + +const REQUESTS: Record = { + REMOVE_HOLD: 'Remove the hold', + UNFREEZE: 'Unfreeze the account', + RESTORE_DIGITAL_SERVICES: 'Restore digital services' +}; + +/** + * GRM grievances raised by the bank for account holders who contest an I4C hold: EDD, raising on GRM, the IO / DGRO / + * SGRO decision with its deadlines, escalation, and carrying an allowing order out on the account. + */ +@Component({ + selector: 'mifosx-i4c-grievances', + templateUrl: './i4c-grievances.component.html', + styleUrls: ['./i4c-grievances.component.scss'], + imports: [ + DatePipe, + DecimalPipe, + FormsModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatProgressBarModule, + MatPaginatorModule, + FaIconComponent, + I4cDateRangeComponent, + I4cDocumentsComponent + ] +}) +export class I4cGrievancesComponent implements OnInit { + private i4c = inject(I4cService); + private route = inject(ActivatedRoute); + private snackBar = inject(MatSnackBar); + + readonly stages = GRM_STAGES; + readonly decisions = GRM_DECISIONS; + readonly noteEvents = [ + { value: 'VC_REQUESTED', label: 'IO requested a VC' }, + { value: 'VC_DONE', label: 'VC held' }, + { value: 'VC_NOT_REQUIRED', label: 'VC not required' }, + { value: 'INFO_SOUGHT', label: 'Additional information sought' }, + { value: 'INFO_SENT', label: 'Information sent' }, + { value: 'NOTE', label: 'Note' } + ]; + readonly pageSizes = [ + 10, + 20, + 50, + 100 + ]; + + stage = 'OPEN'; + status = ''; + overdueOnly = false; + search = ''; + dates: I4cDateRange = { ...ALL_DATES }; + rows: any[] = []; + total = 0; + pageIndex = 0; + pageSize = 20; + loading = false; + error = ''; + + selected: any = null; + busy = false; + tab: 'overview' | 'workflow' | 'documents' | 'history' = 'workflow'; + edd = { kycVerified: true, sourceOfFunds: '', customerStatement: '', risk: 'LOW', recommendation: 'SUPPORT' }; + grievanceNo = ''; + remarks = ''; + noteType = 'VC_REQUESTED'; + decision = { decision: 'REMOVE_HOLD', keepAmount: null as number | null, orderRef: '', orderDate: '', remarks: '' }; + + ngOnInit(): void { + const open = Number(this.route.snapshot.queryParamMap.get('open')); + this.load(); + if (open) { + this.open(open); + } + } + + load(fromFirstPage = false): void { + if (fromFirstPage) { + this.pageIndex = 0; + } + this.loading = true; + this.error = ''; + this.i4c + .grievances({ + stage: this.stage, + status: this.status, + overdue: this.overdueOnly || null, + search: this.search.trim(), + fromDate: this.dates.fromDate, + toDate: this.dates.toDate, + offset: this.pageIndex * this.pageSize, + limit: this.pageSize + }) + .subscribe({ + next: (page) => { + this.rows = page.pageItems; + this.total = page.totalFilteredRecords; + this.loading = false; + }, + error: (e) => { + this.error = i4cErrorMessage(e); + this.loading = false; + } + }); + } + + setStage(s: string): void { + this.stage = s; + this.load(true); + } + + onPage(e: PageEvent): void { + this.pageIndex = e.pageIndex; + this.pageSize = e.pageSize; + this.load(); + } + + onDates(range: I4cDateRange): void { + this.dates = range; + this.load(true); + } + + get hasFilters(): boolean { + return !!(this.status || this.overdueOnly || this.search || this.dates.preset !== 'ALL' || this.stage !== 'OPEN'); + } + + clearFilters(): void { + this.stage = 'OPEN'; + this.status = ''; + this.overdueOnly = false; + this.search = ''; + this.dates = { ...ALL_DATES }; + this.load(true); + } + + open(id: number): void { + this.i4c.grievance(id).subscribe({ + next: (g) => { + const same = this.selected?.id === g.id; + this.selected = g; + if (!same) { + this.tab = 'workflow'; + this.remarks = ''; + this.grievanceNo = g.grievance_no || ''; + this.decision = { decision: 'REMOVE_HOLD', keepAmount: null, orderRef: '', orderDate: '', remarks: '' }; + } + }, + error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }) + }); + } + + close(): void { + this.selected = null; + } + + stageLabel(s: string): string { + return GRM_STAGES.find((x) => x.value === s)?.label || s; + } + + decisionLabel(d: string): string { + return GRM_DECISIONS.find((x) => x.value === d)?.label || d; + } + + requestLabel(r: string): string { + return REQUESTS[r] || r; + } + + /** The stages on the way, for the little progress line; escalation stages show only once reached. */ + flow(g: any): { value: string; label: string; state: 'done' | 'now' | 'todo' }[] { + const path = [ + 'DRAFT', + 'NODAL_REVIEW', + 'WITH_IO', + 'IO_INVESTIGATION', + 'WITH_DGRO', + 'WITH_SGRO', + 'CLOSED' + ]; + const at = path.indexOf(g.stage); + const visible = path.filter((s, i) => i <= Math.max(at, 3) || s === 'CLOSED' || i <= at); + return visible.map((s) => ({ + value: s, + label: (GRM_STAGES.find((x) => x.value === s)?.label || s).replace(/ \(.*\)/, ''), + state: path.indexOf(s) < at ? 'done' : s === g.stage ? 'now' : 'todo' + })); + } + + get level(): string { + const s = this.selected?.stage; + return s === 'WITH_DGRO' ? 'DGRO' : s === 'WITH_SGRO' ? 'SGRO' : 'IO'; + } + + get withOfficer(): boolean { + return [ + 'WITH_IO', + 'IO_INVESTIGATION', + 'WITH_DGRO', + 'WITH_SGRO' + ].includes(this.selected?.stage); + } + + isLate(ts: string | null): boolean { + return !!ts && new Date(ts).getTime() < Date.now(); + } + + step(step: string, body: Record, done: string): void { + if (!this.selected) { + return; + } + this.busy = true; + this.i4c.grievanceStep(this.selected.id, step, body).subscribe({ + next: (g) => { + this.busy = false; + this.selected = g; + this.remarks = ''; + this.snackBar.open(done, 'OK', { duration: 4000 }); + this.load(); + }, + error: (e) => { + this.busy = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 }); + } + }); + } + + saveEdd(): void { + this.step('edd', this.edd, 'Due diligence recorded.'); + } + + submit(): void { + this.step('submit', { grievanceNo: this.grievanceNo.trim() }, 'Grievance marked as raised on GRM.'); + } + + nodal(approve: boolean): void { + this.step( + 'nodal', + { approve, remarks: this.remarks }, + approve ? 'Approved; now with the IO (7 days).' : 'Grievance rejected by the nodal officer.' + ); + } + + note(): void { + this.step('events', { eventType: this.noteType, remarks: this.remarks }, 'Noted.'); + } + + investigation(): void { + this.step('investigation', { remarks: this.remarks }, 'IO investigation recorded (15 days).'); + } + + decide(): void { + this.step( + 'decision', + { + level: this.level, + decision: this.decision.decision, + keepAmount: this.decision.decision === 'PARTIAL_HOLD' ? this.decision.keepAmount : null, + orderRef: this.decision.orderRef, + orderDate: this.decision.orderDate || null, + remarks: this.decision.remarks + }, + 'Decision recorded.' + ); + } + + escalate(): void { + this.step('escalate', { remarks: this.remarks }, 'Escalated.'); + } + + withdraw(): void { + this.step('withdraw', { remarks: this.remarks }, 'Grievance withdrawn.'); + } + + raiseAction(): void { + if (!this.selected) { + return; + } + this.busy = true; + this.i4c.raiseGrievanceAction(this.selected.id).subscribe({ + next: (r) => { + this.busy = false; + this.snackBar.open( + r.actionRequestId + ? 'Request ' + r.actionRequestId + ' raised. Another officer approves it on the Approvals page.' + : 'Nothing to change on the account; grievance closed.', + 'OK', + { duration: 6000 } + ); + this.open(this.selected.id); + this.load(); + }, + error: (e) => { + this.busy = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 }); + } + }); + } + + missingDocs(g: any): string[] { + return (g.requiredDocuments || []).filter((t: string) => !(g.documents || []).some((d: any) => d.doc_type === t)); + } + + get escalateLabel(): string { + const s = this.selected?.stage; + return s === 'WITH_SGRO' + ? 'Close: customer may approach the court' + : s === 'WITH_DGRO' + ? 'Escalate to State GRO' + : 'Escalate to District GRO'; + } +} diff --git a/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.html b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.html new file mode 100644 index 000000000..6d16f8d71 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.html @@ -0,0 +1,794 @@ + + +
+ +
+
+ @for (v of views; track v.value) { + + } +
+ + + + + Account / acknowledgement / customer + + + + + + {{ total }} holds +
+ +
+ + Account type + + All accounts + Savings + Current + + + + Customer intimation + + Any + Customer told + Not yet told + + + + Layer + + All layers + Layer 0 + Layer 1+ + + + Debits frozen only + + +
+ + @if (loading || busy) { + + } + @if (error) { +
+ + {{ error }} +
+ } + + +
+
+ + I4C Account Holds Register + Only disputed amounts are restricted; remaining balance stays usable by customer. +
+ +
+ + + + + + + + + + + + + + + + + @for (h of rows; track h.id) { + + + + + + + + + + + + + } @empty { + + + + } + +
Customer AccountAccount Holder / BranchAck No.LayerDisputed (₹)Held (₹)Short (₹)Hold StatusCustomer Intimation90-Day Review
+
+ {{ h.account_no }} + {{ h.account_type }} +
+
+
{{ h.client_name || 'N/A' }}
+
{{ h.office_name }}
+
+
+ {{ h.acknowledgement_no }} + +
+
{{ h.layer }}₹{{ h.disputed_amount | number: '1.2-2' }}₹{{ h.amount_held | number: '1.2-2' }} + @if (h.shortfall > 0) { + ₹{{ h.shortfall | number: '1.2-2' }} + } @else { + - + } + + {{ h.status }} + @if (h.debit_frozen) { + DEBIT FROZEN + } + @if (h.pending_actions) { + APPROVAL PENDING + } + + @if (h.customer_intimated_at) { + + + {{ h.customer_intimated_at | date: 'dd MMM' }} ({{ h.intimation_mode }}) + + } @else if (isOverdue(h)) { + OVERDUE + } @else if (h.amount_held > 0 && h.status === 'ACTIVE') { + INTIMATION DUE + } @else { + - + } + + + @if (isReviewDue(h)) { + + } + {{ h.review_due_on }} + +
+
+
No holds found in this view.
+
+
+ +
+ + + @if (selected; as h) { +
+
+ +
+
+ +
+ +
+ Customer: {{ h.client_name || 'N/A' }} ({{ h.office_name }}) • Ack: {{ h.acknowledgement_no }} +
+
+
+
+ +
+
+ + +
+ + + @if ((h.status === 'ACTIVE' && h.amount_held > 0) || h.intimationLog?.length) { + + } + @if (allowedActions.length > 0) { + + } + + @if (h.actions?.length) { + + } +
+ + +
+ + @if (modalTab === 'overview') { +
+
+
Hold Balances & Account
+
+
+
Customer Name
+
{{ h.client_name || '-' }} ({{ h.office_name }})
+ +
Account Number
+
+ {{ h.account_no }} ({{ h.account_type }}) +
+ +
I4C Complaint Ack
+
+ {{ h.acknowledgement_no }} + (Layer {{ h.layer }}) +
+ +
Disputed Amount
+
₹{{ h.disputed_amount | number: '1.2-2' }}
+ +
Amount Held
+
+ ₹{{ h.amount_held | number: '1.2-2' }} +
+ +
Shortfall
+
+ @if (h.shortfall > 0) { + ₹{{ h.shortfall | number: '1.2-2' }} (Prospective hold) + } @else { + Fully covered (₹0.00) + } +
+
+
+
+ +
+
Compliance & Directions
+
+
+
Hold Placed On
+
{{ h.held_at | date: 'dd MMM yyyy, HH:mm:ss' }} (System automated)
+ +
90-Day Review Due
+
+ {{ h.review_due_on }} +
+ +
Debit Freeze Status
+
+ @if (h.debit_frozen) { + FROZEN + } @else { + Active (Debit Not Frozen) + } +
+ +
Customer Intimation
+
+ @if (h.customer_intimated_at) { + + {{ h.customer_intimated_at | date: 'dd MMM yyyy, HH:mm' }} by {{ h.intimated_by }} ({{ + h.intimation_mode + }}{{ h.intimation_reference ? ', ' + h.intimation_reference : '' }}) + + } @else { + + Not told yet{{ + h.intimation_due_by ? ' - due by ' + (h.intimation_due_by | date: 'dd MMM yyyy, HH:mm') : '' + }} + + } +
+ + @if (h.status === 'RELEASED') { +
Released On
+
+ {{ h.released_at | date: 'dd MMM yyyy, HH:mm' }} - + {{ reasonLabel(h.release_reason_type || '') }} + {{ h.release_reference }} +
+ } +
+
+
+
+ } + + + @if (modalTab === 'txns') { +
+
Account Hold Transactions
+
+ + + + + + + + + + + @for (t of h.transactions; track t.id) { + + + + + + + } @empty { + + + + } + +
Account Txn IDHeld Amount (₹)Placed TimestampReleased Timestamp
+ {{ t.account_txn_id }} + ₹{{ t.amount | number: '1.2-2' }}{{ t.placed_at | date: 'dd MMM yyyy, HH:mm' }}{{ t.released_at ? (t.released_at | date: 'dd MMM yyyy, HH:mm') : '-' }}
+ No money held yet (prospective hold waiting for funds deposit). +
+
+
+ } + + + @if (modalTab === 'intimation') { + @if (h.customer_intimated_at) { +
+ +
+ Customer told on {{ h.customer_intimated_at | date: 'dd MMM yyyy, HH:mm' }} by + {{ channelLabel(h.intimation_mode || '') + }}{{ h.intimation_reference ? ' - ' + h.intimation_reference : '' }} ({{ h.intimated_by }}) +
+
+ } @else if (h.automaticChannels?.length) { +
+ +
+ Automatic intimation is on ({{ autoChannelsText(h) }}). + @if (h.intimationLog?.length) { + The last automatic attempt did not reach the customer; the system retries up to 3 times. You can + send again now, or call the customer and record it below. + } @else { + The notice is sent as soon as the hold is placed. If it has not gone yet, send it now. + } +
+ +
+ } @else { +
+ +
+ No automatic channel is configured. Call the customer (or send a letter / meet in person), then + record it below. Channels are switched on under Intimation Settings. +
+
+ } + + @if (!h.customer_intimated_at && h.status === 'ACTIVE' && h.amount_held > 0) { +
+
Record Customer Intimation (manual)
+
+
+ RBI draft directions mandate that the account holder be notified of an account hold by the end of + the next day, including reason, restoration options, and Grievance Officer contacts. +
+
+ + How the customer was told + + @for (m of intimationModes; track m) { + {{ channelLabel(m) }} + } + + + + + Reference (call note, letter no.) + + +
+ +
+
+ } + +
+
+ Intimation History ({{ h.intimationLog?.length || 0 }}) +
+
+ + + + + + + + + + + + + + @for (l of h.intimationLog || []; track l.id) { + + + + + + + + + + } @empty { + + + + } + +
WhenChannelTypeStatusSent toReference / ReasonBy
{{ l.created_at | date: 'dd MMM yyyy, HH:mm' }}{{ channelLabel(l.channel) }} + {{ l.mode === 'AUTO' ? 'Automatic' : l.mode === 'RESEND' ? 'Sent by officer' : 'Recorded' }} + + {{ l.status }} + {{ l.destination_masked || '-' }}{{ l.failure_reason || l.provider_reference || '-' }}{{ l.actor || '-' }}
No notice sent or recorded yet.
+
+
+ } + + + @if (modalTab === 'action') { +
+
Raise Request on Hold (Maker)
+
+ @if (hasPendingAction) { +
+ A request on this hold is already waiting for checker approval on the Approvals page. +
+ } @else { +
+ + Requested Action + + @for (a of allowedActions; track a.value) { + {{ a.label }} + } + + + + @if (action.action === 'RELEASE') { + + Amount to release (₹, empty = all) + + @if (remainingAfterRelease !== null) { + ₹{{ remainingAfterRelease | number: '1.2-2' }} stays on hold (order names the amount to + keep) + } + + } + + + Reason + + @for (r of reasonTypes; track r.value) { + {{ r.label }} + } + + + + + Order / Reference No.{{ orderRequired ? ' *' : '' }} + + + + + Order Date + + + + + Issuing Authority (IO, Court, DGRO...) + + + + + Maker Remarks + + +
+ + } +
+
+ } + + + @if (modalTab === 'cases') { +
+
+
+ GRM grievance (customer contests the hold) +
+
+ @for (g of h.grievances || []; track g.id) { +
+ {{ g.grievance_no || 'Draft #' + g.id }} + {{ g.stage }} + {{ g.status }} + {{ g.created_at | date: 'dd MMM yyyy' }} +
+ } + @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.
+ } +
+
+ +
+
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' }}) + + + } + + Remarks + + +
+ + } @else if (!h.restorations?.length) { +
No restoration on this hold.
+ } +
+
+
+ +
+
+ Bank statement (GRM / MRM ask for it) +
+
+
+ + From (default 90 days back) + + + + To (default today) + + +
+ +
+
+ +
+
+ Documents on this hold (IO / court orders...) +
+
+ +
+
+ } + + + @if (modalTab === 'history') { +
+
Requests Audit History on this Hold
+
+ + + + + + + + + + + + @for (a of h.actions; track a.id) { + + + + + + + + } + +
ActionReason / OrderStatusMaker DetailsChecker Details
+ {{ a.action }} + + {{ reasonLabel(a.reason_type) }} +
{{ a.order_reference }} {{ a.authority ? '- ' + a.authority : '' }}
+
+ {{ a.status }} + @if (a.rejection_reason || a.failure_message) { +
{{ a.rejection_reason || a.failure_message }}
+ } +
+ {{ a.maker_name }} +
{{ a.maker_at | date: 'dd MMM, HH:mm' }}
+
+ {{ a.checker_name || '-' }} + @if (a.checker_at) { +
{{ a.checker_at | date: 'dd MMM, HH:mm' }}
+ } +
+
+
+ } +
+ + + +
+
+ } +
diff --git a/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.scss b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.scss new file mode 100644 index 000000000..618708fea --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.scss @@ -0,0 +1,236 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use '../i4c' as *; + +@include i4c-skin; + +.search-input { + width: 240px; +} + +.search-btn { + height: 38px; + border-radius: 8px; + font-weight: 600; + font-size: 11.5px; +} + +.count-tag { + font-size: 11px; + font-weight: 700; + color: #64748b; + background: #f1f5f9; + padding: 2px 7px; + border-radius: 9999px; + border: 1px solid #e2e8f0; +} + +.head-hint { + margin-left: auto; + font-size: 11px; + color: #94a3b8; + font-weight: normal; +} + +.acc-cell { + display: flex; + align-items: center; + gap: 6px; + + b { + color: #0f172a; + font-size: 12px; + } +} + +.type-tag { + padding: 1px 6px; + border-radius: 4px; + background: #f1f5f9; + font-size: 10px; + font-weight: 700; + color: #64748b; +} + +.client-name { + font-weight: 600; + color: #1e293b; +} + +.ack-cell { + display: flex; + align-items: center; + gap: 6px; + + .ack-no { + font-weight: 600; + color: #334155; + font-size: 12px; + } +} + +.short-pill { + display: inline-block; + padding: 2px 6px; + border-radius: 6px; + background: #fef2f2; + color: #dc2626; + font-weight: 700; + font-size: 11px; +} + +.intimation-ok { + display: inline-flex; + align-items: center; + gap: 5px; + color: #059669; + font-weight: 600; + font-size: 12px; + + fa-icon { + font-size: 13px; + } +} + +.review-date { + font-size: 12px; + color: #475569; + font-weight: 500; +} + +.due-alert { + color: #d97706; + font-weight: 700; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.modal-title-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.sub { + font-size: 11px; + color: #64748b; + margin-top: 2px; +} + +.err-text { + color: #dc2626; + font-size: 12px; +} + +.text-accent { + color: #2563eb; +} + +.amount-held-text { + color: #0f172a; + font-size: 13.5px; +} + +.text-muted { + color: #94a3b8; +} + +.footer-btn-close { + padding: 6px 14px; + border-radius: 8px; + background: #e2e8f0; + color: #334155; + border: none; + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #cbd5e1; + color: #0f172a; + } +} + +.intim-status { + display: flex; + gap: 12px; + align-items: center; + padding: 12px 16px; + border-radius: 10px; + margin-bottom: 12px; + font-size: 13px; + line-height: 1.5; + border: 1px solid; + + > div { + flex: 1 1 auto; + } + + &--done { + border-color: #bbf7d0; + background: #f0fdf4; + color: #166534; + } + + &--auto { + border-color: #bfdbfe; + background: #eff6ff; + color: #1e3a8a; + } + + &--manual { + border-color: #fde68a; + background: #fffbeb; + color: #92400e; + } +} + +.wrap { + white-space: normal; + overflow-wrap: anywhere; + max-width: 320px; +} + +.cases-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +@media (width <= 900px) { + .cases-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.case-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 10px; + margin-bottom: 8px; + border: 1px solid #e2e8f0; + border-radius: 8px; + cursor: pointer; + + &:hover, + &:focus-visible { + border-color: #2563eb; + outline: none; + } + + .sub { + font-size: 11px; + color: #64748b; + } +} diff --git a/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.ts b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.ts new file mode 100644 index 000000000..b6f1be221 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-holds/i4c-holds.component.ts @@ -0,0 +1,455 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { DatePipe, DecimalPipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { HOLD_REASON_TYPES, HoldActionRequest, I4cHold, I4cService, i4cErrorMessage, saveBlob } from '../i4c.service'; +import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component'; +import { MRM_BASES } from '../i4c-restorations/i4c-restorations.component'; +import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component'; + +/** + * The register of every hold placed for I4C. Placing is automatic; from here an officer asks for a release or a + * debit freeze/unfreeze (approved by someone else on the Approvals page), and records that the customer was told + * about the hold. + */ +@Component({ + selector: 'mifosx-i4c-holds', + templateUrl: './i4c-holds.component.html', + styleUrls: ['./i4c-holds.component.scss'], + imports: [ + DatePipe, + DecimalPipe, + FormsModule, + RouterLink, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatCheckboxModule, + MatProgressBarModule, + MatPaginatorModule, + I4cDateRangeComponent, + I4cDocumentsComponent, + FaIconComponent + ] +}) +export class I4cHoldsComponent implements OnInit { + private i4c = inject(I4cService); + private route = inject(ActivatedRoute); + private router = inject(Router); + private snackBar = inject(MatSnackBar); + + readonly views = [ + { value: 'ACTIVE', label: 'Active' }, + { value: 'PROSPECTIVE', label: 'Prospective (still short)' }, + { value: 'INTIMATION_DUE', label: 'Customer not yet told' }, + { value: 'REVIEW_DUE', label: '90-day review due' }, + { value: 'NOT_HOLDABLE', label: 'Not holdable' }, + { value: 'RELEASED', label: 'Released' }, + { value: '', label: 'All' } + ]; + readonly reasonTypes = HOLD_REASON_TYPES; + /** Ways an officer can tell the customer and record it by hand; phone call comes first. */ + readonly intimationModes = [ + 'PHONE', + 'LETTER', + 'IN_PERSON', + 'SMS', + 'EMAIL' + ]; + + view = 'ACTIVE'; + search = ''; + accountType = ''; + intimationFilter = ''; + layer = ''; + frozenOnly = false; + dates: I4cDateRange = { ...ALL_DATES }; + total = 0; + pageIndex = 0; + pageSize = 20; + readonly pageSizes = [ + 10, + 20, + 50, + 100 + ]; + rows: I4cHold[] = []; + selected: I4cHold | null = null; + loading = false; + busy = false; + error = ''; + modalTab: 'overview' | 'txns' | 'intimation' | 'action' | 'cases' | 'history' = 'overview'; + readonly mrmBases = MRM_BASES; + grievanceForm = { customerRequest: 'REMOVE_HOLD', customerReason: '' }; + assessForm = { basis: 'ATTRIBUTION', restorableAmount: null as number | null, remarks: '' }; + statement = { fromDate: '', toDate: '' }; + copiedKey = ''; + + action: HoldActionRequest = this.emptyAction(); + intimation = { mode: 'PHONE', reference: '' }; + + ngOnInit(): void { + const view = this.route.snapshot.queryParamMap.get('view'); + if (view !== null) { + this.view = view; + } + this.load(); + } + + setView(v: string): void { + this.view = v; + this.load(true); + } + + onPage(e: PageEvent): void { + this.pageIndex = e.pageIndex; + this.pageSize = e.pageSize; + this.load(); + } + + onDates(range: I4cDateRange): void { + this.dates = range; + this.load(true); + } + + get hasFilters(): boolean { + return !!( + this.search || + this.accountType || + this.intimationFilter || + this.layer || + this.frozenOnly || + this.dates.preset !== 'ALL' + ); + } + + /** Clears the extra filters; the view chip (Active, Released, ...) stays as chosen. */ + clearFilters(): void { + this.search = ''; + this.accountType = ''; + this.intimationFilter = ''; + this.layer = ''; + this.frozenOnly = false; + this.dates = { ...ALL_DATES }; + this.load(true); + } + + /** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */ + load(fromFirstPage = false): void { + if (fromFirstPage) { + this.pageIndex = 0; + } + this.loading = true; + this.error = ''; + this.i4c + .holds({ + view: this.view, + search: this.search.trim(), + accountType: this.accountType, + intimation: this.intimationFilter, + layer: this.layer, + frozen: this.frozenOnly || null, + fromDate: this.dates.fromDate, + toDate: this.dates.toDate, + offset: this.pageIndex * this.pageSize, + limit: this.pageSize + }) + .subscribe({ + next: (page) => { + this.rows = page.pageItems; + this.total = page.totalFilteredRecords; + this.loading = false; + if (this.selected) { + this.select(this.selected.id); + } + }, + error: (e) => { + this.error = i4cErrorMessage(e); + this.loading = false; + } + }); + } + + select(id: number): void { + this.i4c.hold(id).subscribe({ + next: (hold) => { + this.selected = hold; + this.action = this.emptyAction(); + this.intimation = { mode: 'PHONE', reference: '' }; + this.modalTab = 'overview'; + }, + error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }) + }); + } + + copyText(text: string, key: string, event?: Event): void { + if (event) { + event.stopPropagation(); + } + if (!text) { + return; + } + navigator.clipboard.writeText(text); + this.copiedKey = key; + this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 }); + setTimeout(() => { + if (this.copiedKey === key) { + this.copiedKey = ''; + } + }, 2000); + } + + close(): void { + this.selected = null; + } + + /** What a maker may ask for on this hold, given its state. */ + get allowedActions(): { value: string; label: string }[] { + const h = this.selected; + if (!h || h.status === 'RELEASED') { + return []; + } + const list = [{ value: 'RELEASE', label: 'Release hold' }]; + if (h.account_type === 'SAVINGS') { + list.push( + h.debit_frozen + ? { value: 'UNFREEZE', label: 'Unfreeze debits, hold continues' } + : { value: 'FREEZE', label: 'Freeze all debits (on explicit LEA / court direction only)' } + ); + } + if (h.debit_frozen) { + list.push({ value: 'UNFREEZE_AND_RELEASE', label: 'Unfreeze and remove the hold' }); + } + return list; + } + + get orderRequired(): boolean { + return ( + (this.action.action === 'RELEASE' || this.action.action === 'UNFREEZE_AND_RELEASE') && + this.action.reasonType !== 'REVIEW_90_DAYS' + ); + } + + /** What stays on hold after a partial release; null when the whole hold is released. */ + get remainingAfterRelease(): number | null { + const h = this.selected; + const amount = Number(this.action.amount); + if (!h || this.action.action !== 'RELEASE' || !amount) { + return null; + } + return Math.max(0, Number(h.amount_held) - amount); + } + + get openGrievance(): any { + return (this.selected?.grievances || []).find((g: any) => g.stage !== 'CLOSED'); + } + + get openRestoration(): any { + return (this.selected?.restorations || []).find((r: any) => r.status !== 'CLOSED'); + } + + raiseGrievance(): void { + if (!this.selected) { + return; + } + this.busy = true; + this.i4c.createGrievance(this.selected.id, this.grievanceForm).subscribe({ + next: (r) => { + this.busy = false; + this.router.navigate(['/cfcfrms/i4c/grievances'], { queryParams: { open: r.resourceId } }); + }, + error: (e) => { + this.busy = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 }); + } + }); + } + + assessForMrm(): void { + if (!this.selected) { + return; + } + const possible = this.assessForm.basis === 'ATTRIBUTION' || this.assessForm.basis === 'PRO_RATA'; + this.busy = true; + this.i4c + .assessRestoration(this.selected.id, { + basis: this.assessForm.basis, + restorableAmount: possible ? this.assessForm.restorableAmount : null, + remarks: this.assessForm.remarks + }) + .subscribe({ + next: (r) => { + this.busy = false; + this.router.navigate(['/cfcfrms/i4c/restorations'], { queryParams: { open: r.resourceId } }); + }, + error: (e) => { + this.busy = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 }); + } + }); + } + + goTo(page: 'grievances' | 'restorations', id: number): void { + this.router.navigate(['/cfcfrms/i4c/' + page], { queryParams: { open: id } }); + } + + downloadStatement(): void { + if (!this.selected) { + return; + } + const h = this.selected; + this.i4c.statementCsv(h.id, this.statement.fromDate || undefined, this.statement.toDate || undefined).subscribe({ + next: (blob) => saveBlob(blob, 'statement-' + h.account_no + '.csv'), + error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }) + }); + } + + get hasPendingAction(): boolean { + return (this.selected?.actions || []).some((a: any) => a.status === 'PENDING'); + } + + submitAction(): void { + if (!this.selected) { + return; + } + if (!this.action.reasonType) { + this.snackBar.open('Choose the reason.', 'Close', { duration: 4000 }); + return; + } + if (this.orderRequired && !this.action.orderReference?.trim()) { + this.snackBar.open('An order / reference number is required to release a hold.', 'Close', { duration: 5000 }); + return; + } + this.busy = true; + this.i4c.requestHoldAction(this.selected.id, this.action).subscribe({ + next: () => { + this.busy = false; + this.snackBar.open('Request raised. Another officer must approve it on the Approvals page.', 'OK', { + duration: 5000 + }); + this.load(); + }, + error: (e) => { + this.busy = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + } + }); + } + + submitIntimation(): void { + if (!this.selected) { + return; + } + this.busy = true; + this.i4c.recordIntimation(this.selected.id, this.intimation.mode, this.intimation.reference).subscribe({ + next: () => { + this.busy = false; + this.snackBar.open('Customer intimation recorded.', 'OK', { duration: 3000 }); + this.load(); + }, + error: (e) => { + this.busy = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + } + }); + } + + /** Send the hold notice now on the switched-on automatic channels (Email today). */ + sendIntimationNow(): void { + if (!this.selected) { + return; + } + this.busy = true; + this.i4c.sendIntimation(this.selected.id).subscribe({ + next: (r) => { + this.busy = false; + this.snackBar.open(r.message, 'OK', { duration: 6000 }); + this.load(); + }, + error: (e) => { + this.busy = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + } + }); + } + + channelLabel(channel: string): string { + return ( + ( + { + EMAIL: 'Email', + SMS: 'SMS', + WHATSAPP: 'WhatsApp', + PHONE: 'Phone call', + LETTER: 'Letter', + IN_PERSON: 'In person' + } as Record + )[channel] ?? channel + ); + } + + autoChannelsText(hold: I4cHold): string { + return (hold.automaticChannels || []).map((c) => this.channelLabel(c)).join(', then '); + } + + isOverdue(hold: I4cHold): boolean { + return ( + hold.status === 'ACTIVE' && + !hold.customer_intimated_at && + !!hold.intimation_due_by && + new Date(hold.intimation_due_by).getTime() < Date.now() + ); + } + + isReviewDue(hold: I4cHold): boolean { + if (hold.status !== 'ACTIVE' || !hold.review_due_on) { + return false; + } + const due = new Date(hold.review_due_on + 'T00:00:00').getTime(); + return due - Date.now() <= 15 * 24 * 3600 * 1000; + } + + statusClass(hold: I4cHold): string { + if (hold.status === 'RELEASED') { + return 'i4c-badge--blue'; + } + if (hold.status === 'NOT_HOLDABLE') { + return 'i4c-badge--red'; + } + return hold.shortfall > 0 ? 'i4c-badge--amber' : 'i4c-badge--green'; + } + + reasonLabel(value: string): string { + return this.reasonTypes.find((r) => r.value === value)?.label || value; + } + + private emptyAction(): HoldActionRequest { + return { + action: 'RELEASE', + amount: null, + reasonType: '', + orderReference: '', + orderDate: '', + authority: '', + remarks: '' + }; + } +} diff --git a/src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.html b/src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.html new file mode 100644 index 000000000..12d00b9eb --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.html @@ -0,0 +1,148 @@ + + +
+ @if (loading) { + + } + @if (error) { +
{{ error }}
+ } + +
+ +
+ @if (enabledChannels.length) { +
+ Customers are told automatically by + @for (c of enabledChannels; track c.channel; let last = $last) { + {{ info(c.channel).label }}{{ last ? '' : ', then ' }} + } +
+
+ As soon as money is held, the channels are tried in this order. The first that reaches the customer records + the intimation on the hold. If none does (no email on file, gateway down), the hold stays "customer not yet + told" for an officer to call and record by hand. +
+ } @else { +
No automatic channel is switched on
+
+ Every hold waits for an officer to contact the customer (phone, letter, in person) and record it on the hold's + Record Intimation tab. +
+ } +
+
+ + @for (c of channels; track c.channel) { +
+
+ + {{ info(c.channel).label }} + {{ c.enabled ? 'ON' : 'OFF' }} + + + Send automatically + +
+
+
Sent through: {{ info(c.channel).gateway }}
+ +
+
+
+ + Order + + + @if (c.channel === 'EMAIL') { + + Subject + + + } +
+ + Message + + +
+ Insert: + @for (p of placeholders; track p) { + + } +
+
+ + @if (c.updated_at) { + Last changed {{ c.updated_at | date: 'dd MMM yyyy, HH:mm' }} by {{ c.updated_by }} + } +
+
+ +
+
Preview (sample values)
+ @if (c.channel === 'EMAIL') { +
{{ preview(c.subject) }}
+ } +
{{ preview(c.body) }}
+
+
+ + @if (c.channel === 'EMAIL') { +
+ + Send a test to + + + + Uses the saved message with sample values; the subject starts with [TEST]. +
+ } +
+
+ } + +
+ Phone, letter and in-person intimation are always recorded by an officer on the hold's Record Intimation tab; the + CBS cannot send them itself. Every notice, automatic or recorded, is listed in the hold's intimation history. +
+
diff --git a/src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.scss b/src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.scss new file mode 100644 index 000000000..22673250e --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.scss @@ -0,0 +1,172 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use '../i4c' as *; + +@include i4c-skin; + +.summary { + display: flex; + gap: 12px; + align-items: flex-start; + padding: 12px 16px; + border-radius: 10px; + border: 1px solid #bbf7d0; + background: #f0fdf4; + color: #166534; + + &--manual { + border-color: #fde68a; + background: #fffbeb; + color: #92400e; + } + + &__icon { + margin-top: 2px; + } + + &__title { + font-weight: 600; + font-size: 14px; + } + + &__text { + font-size: 13px; + margin-top: 2px; + line-height: 1.5; + } +} + +.channel--off { + opacity: 0.85; +} + +.gateway { + font-size: 12px; + color: #64748b; + margin-bottom: 10px; +} + +.grid { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + gap: 16px; +} + +@media (width <= 1024px) { + .grid { + grid-template-columns: minmax(0, 1fr); + } +} + +.edit { + min-width: 0; +} + +.row { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.priority { + width: 110px; +} + +.subject { + flex: 1 1 320px; +} + +.full { + width: 100%; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + margin-bottom: 12px; + + &__label { + font-size: 12px; + color: #64748b; + } +} + +.chip { + border: 1px solid #cbd5e1; + background: #f8fafc; + border-radius: 999px; + padding: 2px 10px; + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: #334155; + cursor: pointer; + + &:hover { + border-color: #2563eb; + color: #1d4ed8; + } + + &:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 1px; + } +} + +.preview { + min-width: 0; + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #f8fafc; + padding: 12px 14px; + align-self: start; + + &__label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + margin-bottom: 8px; + } + + &__subject { + font-weight: 600; + color: #0f172a; + font-size: 13px; + margin-bottom: 8px; + } + + &__body { + white-space: pre-wrap; + font-size: 13px; + line-height: 1.55; + color: #1e293b; + overflow-wrap: anywhere; + } +} + +.test { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid #f1f5f9; + + &__field { + width: 280px; + } +} + +.muted { + font-size: 12px; + color: #64748b; +} diff --git a/src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.ts b/src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.ts new file mode 100644 index 000000000..273315be7 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-intimation-settings/i4c-intimation-settings.component.ts @@ -0,0 +1,185 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { I4cService, IntimationChannel, i4cErrorMessage } from '../i4c.service'; + +/** Sample values for the live preview; the server fills the real ones per hold. */ +const SAMPLE: Record = { + customerName: 'Anshul Rathore', + accountNumber: 'XXXXXXXXXXX0006', + amountHeld: '1500.00', + disputedAmount: '1500.00', + ackNo: '31610201726120', + holdDate: '20-10-2026', + reviewDate: '18-01-2027', + bankName: 'LincPay Demo Co-operative Bank', + branchName: 'Head Office', + grievanceEmail: 'grievance@lpdb.example.in', + customerCare: '1800-200-4570' +}; + +const CHANNEL_INFO: Record = { + EMAIL: { + label: 'Email', + icon: 'envelope', + gateway: "CBS mail server (spring.mail settings), to the customer's email on file" + }, + SMS: { + label: 'SMS', + icon: 'mobile-alt', + gateway: "SMS gateway (fineract.notification.sms), to the customer's mobile on file" + }, + WHATSAPP: { + label: 'WhatsApp', + icon: 'phone', + gateway: "WhatsApp gateway (fineract.notification.whatsapp), to the customer's mobile" + } +}; + +interface EditableChannel extends IntimationChannel { + dirty?: boolean; + saving?: boolean; +} + +/** + * Which channels tell the account holder about an I4C hold on their own. Switched-on channels are tried in priority + * order as soon as money is held; the first that reaches the customer records the intimation. With none switched on, + * or none reaching the customer, the hold waits for an officer to phone or write and record it by hand. + */ +@Component({ + selector: 'mifosx-i4c-intimation-settings', + templateUrl: './i4c-intimation-settings.component.html', + styleUrls: ['./i4c-intimation-settings.component.scss'], + imports: [ + DatePipe, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSlideToggleModule, + MatProgressBarModule, + FaIconComponent + ] +}) +export class I4cIntimationSettingsComponent implements OnInit { + private i4c = inject(I4cService); + private snackBar = inject(MatSnackBar); + + channels: EditableChannel[] = []; + placeholders: string[] = []; + loading = false; + error = ''; + testTo = ''; + testing = false; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.error = ''; + this.i4c.intimationChannels().subscribe({ + next: (s) => { + this.channels = s.channels.map((c) => ({ ...c })); + this.placeholders = s.placeholders; + this.loading = false; + }, + error: (e) => { + this.error = i4cErrorMessage(e); + this.loading = false; + } + }); + } + + get enabledChannels(): EditableChannel[] { + return this.channels.filter((c) => c.enabled).sort((a, b) => a.priority - b.priority); + } + + info(channel: string) { + return CHANNEL_INFO[channel] ?? { label: channel, icon: 'bell', gateway: '' }; + } + + preview(text: string | undefined): string { + return (text ?? '').replace(/\{(\w+)\}/g, (match, key) => SAMPLE[key] ?? match); + } + + insert(channel: EditableChannel, placeholder: string): void { + channel.body = (channel.body ?? '') + placeholder; + channel.dirty = true; + } + + /** The switch saves at once: it decides whether customers get told automatically. */ + toggle(channel: EditableChannel): void { + this.save(channel, { enabled: channel.enabled }); + } + + saveText(channel: EditableChannel): void { + if (!channel.body?.trim()) { + this.snackBar.open('The message text cannot be empty.', 'Close', { duration: 4000 }); + return; + } + if (channel.channel === 'EMAIL' && !channel.subject?.trim()) { + this.snackBar.open('An email needs a subject.', 'Close', { duration: 4000 }); + return; + } + this.save(channel, { priority: channel.priority, subject: channel.subject, body: channel.body }); + } + + private save( + channel: EditableChannel, + body: { enabled?: boolean; priority?: number; subject?: string; body?: string } + ): void { + channel.saving = true; + this.i4c.updateIntimationChannel(channel.channel, body).subscribe({ + next: (rows) => { + const fresh = rows.find((r) => r.channel === channel.channel); + if (fresh) { + Object.assign(channel, fresh, { dirty: false }); + } + channel.saving = false; + this.snackBar.open(this.info(channel.channel).label + ' settings saved.', 'OK', { duration: 3000 }); + }, + error: (e) => { + channel.saving = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + this.load(); + } + }); + } + + sendTest(): void { + if (!this.testTo.trim()) { + this.snackBar.open('Enter an email address for the test.', 'Close', { duration: 4000 }); + return; + } + this.testing = true; + this.i4c.testIntimationChannel('EMAIL', this.testTo.trim()).subscribe({ + next: (r) => { + this.testing = false; + this.snackBar.open(r.outcome === 'SENT' ? 'Test email sent to ' + this.testTo + '.' : r.message, 'OK', { + duration: 6000 + }); + }, + error: (e) => { + this.testing = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + } + }); + } +} diff --git a/src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.html b/src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.html new file mode 100644 index 000000000..77bf74e39 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.html @@ -0,0 +1,149 @@ + + +
+
+ + Filter account / customer / branch / ack + + + + {{ shown.length }} accounts · {{ totals.complaints }} complaints · ₹{{ + totals.held | number: '1.2-2' + }} + held + + +
+ + @if (loading) { + + } + @if (error) { +
+ {{ error }} +
+ } + +
+
+ + MRM: Update Account Balance + + Every account with an active I4C hold. Enter these balances on MRM → Money Distribution Process (one by one or + with MRM's bulk format). Click an account for its pro-rata split. + +
+
+ + + + + + + + + + + + + + + + @for (r of shown; track r.account_type + r.account_id) { + + + + + + + + + + + + } @empty { + + + + } + +
AccountHolder / BranchStatusLedger balanceAvailableOn holdDisputedComplaintsAcknowledgement nos.
+ {{ r.account_no }} +
{{ r.account_type }}{{ r.debit_frozen ? ' · debits frozen' : '' }}
+
+ {{ r.client_name }} +
{{ r.office_name }}
+
{{ r.account_status || '-' }}{{ r.ledger_balance | number: '1.2-2' }}{{ r.available_balance | number: '1.2-2' }} + {{ r.held | number: '1.2-2' }} + {{ r.disputed | number: '1.2-2' }}{{ r.complaints }}{{ r.acknowledgement_nos }}
No accounts with active I4C holds.
+
+
+ +
+
+ + Pro-rata distribution + + MRM: when money from several complaints is mixed in one account, it is shared in proportion to the disputed + amounts. Leave the amount empty to share everything held. + +
+
+
+ + Account no. + + + + Amount to share (₹) + + + +
+ @if (proRata) { + + + + + + + + + + + + @for (h of proRata.holds; track h.id) { + + + + + + + + } + +
Complaint (ack)LayerDisputedHeld for itShare
{{ h.acknowledgement_no }}{{ h.layer }}{{ h.disputed_amount | number: '1.2-2' }}{{ h.amount_held | number: '1.2-2' }} + {{ h.share | number: '1.2-2' }} +
+
+ ₹{{ proRata.amount | number: '1.2-2' }} of ₹{{ proRata.totalHeld | number: '1.2-2' }} held on + {{ proRata.accountNo }}, by disputed amount (total ₹{{ proRata.totalDisputed | number: '1.2-2' }}). A share + never exceeds what that complaint's hold has; use the figure as the restorable amount of that complaint's + restoration. +
+ } +
+
+
diff --git a/src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.scss b/src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.scss new file mode 100644 index 000000000..6779e507a --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.scss @@ -0,0 +1,13 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use '../i4c' as *; +@use '../i4c-cases' as *; + +@include i4c-skin; +@include i4c-case-page; diff --git a/src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.ts b/src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.ts new file mode 100644 index 000000000..524ef8cc1 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-mrm-report/i4c-mrm-report.component.ts @@ -0,0 +1,123 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { DecimalPipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { I4cService, i4cErrorMessage, saveBlob } from '../i4c.service'; + +/** + * MRM "Update Account Balance": every account with an active I4C hold and its balances now, for the bank to update on + * MRM (one by one or with the bulk sheet), plus the pro-rata calculator for accounts with several complaints. + */ +@Component({ + selector: 'mifosx-i4c-mrm-report', + templateUrl: './i4c-mrm-report.component.html', + styleUrls: ['./i4c-mrm-report.component.scss'], + imports: [ + DecimalPipe, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatProgressBarModule, + FaIconComponent + ] +}) +export class I4cMrmReportComponent implements OnInit { + private i4c = inject(I4cService); + private snackBar = inject(MatSnackBar); + + rows: any[] = []; + loading = false; + error = ''; + filter = ''; + proRataAccount = ''; + proRataAmount: number | null = null; + proRata: any = null; + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.error = ''; + this.i4c.mrmBalanceReport().subscribe({ + next: (rows) => { + this.rows = rows; + this.loading = false; + }, + error: (e) => { + this.error = i4cErrorMessage(e); + this.loading = false; + } + }); + } + + get shown(): any[] { + const f = this.filter.trim().toLowerCase(); + return f + ? this.rows.filter((r) => + [ + r.account_no, + r.client_name, + r.office_name, + r.acknowledgement_nos + ].some((v) => + String(v || '') + .toLowerCase() + .includes(f) + ) + ) + : this.rows; + } + + get totals(): { held: number; ledger: number; complaints: number } { + return this.shown.reduce( + (t, r) => ({ + held: t.held + Number(r.held || 0), + ledger: t.ledger + Number(r.ledger_balance || 0), + complaints: t.complaints + Number(r.complaints || 0) + }), + { held: 0, ledger: 0, complaints: 0 } + ); + } + + download(): void { + this.i4c.mrmBalanceReportCsv().subscribe({ + next: (blob) => saveBlob(blob, 'mrm-balance-report-' + new Date().toISOString().slice(0, 10) + '.csv'), + error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }) + }); + } + + pick(r: any): void { + this.proRataAccount = r.account_no; + this.proRataAmount = null; + this.calculate(); + } + + calculate(): void { + if (!this.proRataAccount.trim()) { + return; + } + this.i4c.proRata(this.proRataAccount.trim(), this.proRataAmount).subscribe({ + next: (p) => (this.proRata = p), + error: (e) => { + this.proRata = null; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + } + }); + } +} diff --git a/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.html b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.html new file mode 100644 index 000000000..b15be4f0e --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.html @@ -0,0 +1,497 @@ + + +
+ +
+
+ + + +
+ + + + +
+ + @if (loading) { + + } + @if (error) { +
+ + {{ error }} +
+ } + + + @if (activeSection === 'all' || activeSection === 'outbox') { +
+
+ + Outbox Queue: Delivery to I4C + Automated HTTP callbacks carrying bank findings to I4C + + + + +
+ @for (s of outboxStatuses; track s) { + + } +
+
+ +
+ + Ack no. / job id + + + + Type + + ACK and RESPONSE + ACK only + RESPONSE only + + + + +
+ +
+ + + + + + + + + + + + + + + + + @for (o of outbox; track o.id) { + + + + + + + + + + + + + } @empty { + + + + } + +
Created AtComplaint Ack No.TypeDelivery StatusAttemptsNext RetryHTTP CodeSent TimestampErrorActions
+ {{ o.created_at | date: 'dd MMM, HH:mm:ss' }} + + + {{ o.acknowledgement_no }} + + + {{ + o.message_type === 'ACK' ? 'ACK' : 'RESPONSE' + }} + + + {{ o.status }} + + {{ o.attempts }}{{ o.status === 'PENDING' ? (o.next_attempt_at | date: 'dd MMM, HH:mm:ss') : '-' }} + {{ o.http_status ?? '-' }} + {{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}{{ o.last_error || '-' }} +
+ + @if (o.status === 'FAILED') { + + } +
+
+
+
No responses in the outbox queue matching current filter.
+
+
+ +
+ } + + + @if (activeSection === 'all' || activeSection === 'log') { +
+
+ + API Gateway Audit Trail (Masked) + Every call to and from I4C. Click a row to see its request and response. + + + + +
+ + + +
+ + + Failures Only + +
+ +
+ + Endpoint / ack no. / IP + + + + HTTP status + + Any + 200 OK + 400 Bad request + 401 Not authorised + 403 Forbidden + 409 Duplicate + 500 Server error + + + + Response code + + + + +
+ +
+ + + + + + + + + + + + + + + + @for (l of log; track l.id) { + + + + + + + + + + + + } @empty { + + + + } + +
TimeDirectionEndpointHTTP StatusResponse CodeAck No.Latency (ms)Caller IPError Details
{{ l.created_at | date: 'dd MMM, HH:mm:ss' }} + + {{ l.direction === 'IN' ? '↓ I4C → Bank' : '↑ Bank → I4C' }} + + + {{ l.endpoint }} + + + {{ l.http_status ?? 'N/A' }} + + {{ l.response_code || '-' }} + {{ l.acknowledgement_no || '-' }} + {{ l.duration_ms }} ms{{ l.client_ip || '-' }} + {{ logNote(l.error) || '-' }} +
+
+
No API calls recorded.
+
+
+ +
+ } + + + @if (selectedLogModal) { +
+
+ +
+
+ +
+ +
+ Time: {{ selectedLogModal.created_at | date: 'dd MMM yyyy, HH:mm:ss' }} • Latency: + {{ selectedLogModal.duration_ms }} ms • Client IP: {{ selectedLogModal.client_ip }} +
+
+
+ +
+ + +
+ + +
+ + +
+ @if (logModalTab === 'req') { +
+
+ HTTP Request Payload (Masked) + +
+
{{ pretty(selectedLogModal.request_body) || 'No request body sent.' }}
+
+ } + + @if (logModalTab === 'res') { +
+
+ HTTP Response Payload + +
+
{{ pretty(selectedLogModal.response_body) || 'No response body received.' }}
+
+ } + + @if (selectedLogModal.error) { + @if (isFailedCall(selectedLogModal)) { +
+ + Error logged: {{ logNote(selectedLogModal.error) }} +
+ } @else { +
+ + Note: {{ logNote(selectedLogModal.error) }} +
+ } + } +
+ + + +
+
+ } + + + @if (selectedOutboxModal) { +
+
+
+
+ +
+ +
+ Created: {{ selectedOutboxModal.created_at | date: 'dd MMM yyyy, HH:mm:ss' }} • Attempts: + {{ selectedOutboxModal.attempts }} +
+
+
+ +
+ +
+
+
+ Report Payload Prepared for I4C Delivery + +
+
{{ pretty(selectedOutboxModal.payload) || 'No payload recorded.' }}
+
+ + @if (selectedOutboxModal.response_body) { +
+
+ Response Received from I4C Gateway + +
+
{{ pretty(selectedOutboxModal.response_body) }}
+
+ } + + @if (selectedOutboxModal.last_error) { +
+ + Last Delivery Error: {{ selectedOutboxModal.last_error }} +
+ } +
+ + +
+
+ } +
diff --git a/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.scss b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.scss new file mode 100644 index 000000000..c79acb2f9 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.scss @@ -0,0 +1,191 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use '../i4c' as *; + +@include i4c-skin; + +.compact { + width: 160px; + font-size: 13px; +} + +.sub { + font-size: 11px; + color: #64748b; + margin-bottom: 4px; +} + +.err-text { + color: #dc2626; + font-size: 11px; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.head-hint { + font-size: 11px; + color: #94a3b8; + font-weight: normal; + margin-left: 8px; +} + +.date-main { + font-weight: 600; + color: #1e293b; +} + +.mini-chips { + display: flex; + align-items: center; + gap: 5px; + + .mini-chip { + padding: 2px 8px; + border-radius: 9999px; + font-size: 10.5px; + font-weight: 600; + color: #475569; + background: #f1f5f9; + border: 1px solid #e2e8f0; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: #e2e8f0; + color: #0f172a; + } + + &--active { + background: #2563eb; + color: #fff; + border-color: #2563eb; + box-shadow: 0 2px 6px rgb(37 99 235 / 25%); + } + } +} + +.failures-chk { + margin-left: 10px; + font-size: 11px; + font-weight: 600; + color: #475569; +} + +.direction-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + border-radius: 5px; + font-size: 10px; + font-weight: 700; + + &--in { + background: #eff6ff; + color: #1d4ed8; + border: 1px solid #bfdbfe; + } + + &--out { + background: #f5f3ff; + color: #6d28d9; + border: 1px solid #ddd6fe; + } +} + +.retry-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 5px; + font-size: 10.5px; + font-weight: 700; + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; + cursor: pointer; + transition: all 0.15s ease; + + &:hover:not(:disabled) { + background: #fee2e2; + border-color: #fca5a5; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.modal-title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.payload-box { + display: flex; + flex-direction: column; + gap: 6px; + + &__title { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + font-weight: 700; + color: #1e293b; + } +} + +.copy-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 5px; + background: #f1f5f9; + border: 1px solid #cbd5e1; + color: #334155; + font-size: 10.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: #e2e8f0; + color: #0f172a; + } +} + +.footer-btn-close { + padding: 6px 14px; + border-radius: 8px; + background: #e2e8f0; + color: #334155; + border: none; + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #cbd5e1; + color: #0f172a; + } +} + +.note-text { + color: #64748b; + font-size: 12px; +} diff --git a/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.ts b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.ts new file mode 100644 index 000000000..69bf63e85 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-outbox/i4c-outbox.component.ts @@ -0,0 +1,306 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { I4cService, i4cErrorMessage } from '../i4c.service'; +import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component'; + +/** + * Responses waiting for, or delivered to, I4C, with a retry for the failed ones; and the log of every call in + * either direction, which is what the periodic review of the API integration reads. + */ +@Component({ + selector: 'mifosx-i4c-outbox', + templateUrl: './i4c-outbox.component.html', + styleUrls: ['./i4c-outbox.component.scss'], + imports: [ + DatePipe, + FormsModule, + RouterLink, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatCheckboxModule, + MatProgressBarModule, + MatPaginatorModule, + I4cDateRangeComponent, + FaIconComponent + ] +}) +export class I4cOutboxComponent implements OnInit { + private i4c = inject(I4cService); + private route = inject(ActivatedRoute); + private snackBar = inject(MatSnackBar); + + readonly outboxStatuses = [ + '', + 'PENDING', + 'SENDING', + 'SENT', + 'FAILED' + ]; + readonly pageSizes = [ + 10, + 20, + 50, + 100 + ]; + + outboxStatus = ''; + outboxType = ''; + outboxSearch = ''; + outboxDates: I4cDateRange = { ...ALL_DATES }; + outboxTotal = 0; + outboxPageIndex = 0; + outboxPageSize = 20; + outbox: any[] = []; + + direction = ''; + failuresOnly = false; + logSearch = ''; + logHttpStatus = ''; + logResponseCode = ''; + logDates: I4cDateRange = { ...ALL_DATES }; + logTotal = 0; + logPageIndex = 0; + logPageSize = 20; + log: any[] = []; + logEntry: any = null; + + loading = false; + busyId: number | null = null; + error = ''; + + activeSection: 'all' | 'outbox' | 'log' = 'all'; + selectedLogModal: any = null; + selectedOutboxModal: any = null; + logModalTab: 'req' | 'res' = 'req'; + copiedKey = ''; + + ngOnInit(): void { + this.outboxStatus = this.route.snapshot.queryParamMap.get('status') || ''; + this.loadOutbox(); + this.loadLog(); + } + + setOutboxStatus(s: string): void { + this.outboxStatus = s; + this.loadOutbox(true); + } + + setDirection(d: string): void { + this.direction = d; + this.loadLog(true); + } + + onOutboxPage(e: PageEvent): void { + this.outboxPageIndex = e.pageIndex; + this.outboxPageSize = e.pageSize; + this.loadOutbox(); + } + + onOutboxDates(range: I4cDateRange): void { + this.outboxDates = range; + this.loadOutbox(true); + } + + get outboxHasFilters(): boolean { + return !!(this.outboxStatus || this.outboxType || this.outboxSearch || this.outboxDates.preset !== 'ALL'); + } + + clearOutboxFilters(): void { + this.outboxStatus = ''; + this.outboxType = ''; + this.outboxSearch = ''; + this.outboxDates = { ...ALL_DATES }; + this.loadOutbox(true); + } + + onLogPage(e: PageEvent): void { + this.logPageIndex = e.pageIndex; + this.logPageSize = e.pageSize; + this.loadLog(); + } + + onLogDates(range: I4cDateRange): void { + this.logDates = range; + this.loadLog(true); + } + + get logHasFilters(): boolean { + return !!( + this.direction || + this.failuresOnly || + this.logSearch || + this.logHttpStatus || + this.logResponseCode || + this.logDates.preset !== 'ALL' + ); + } + + clearLogFilters(): void { + this.direction = ''; + this.failuresOnly = false; + this.logSearch = ''; + this.logHttpStatus = ''; + this.logResponseCode = ''; + this.logDates = { ...ALL_DATES }; + this.loadLog(true); + } + + /** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */ + loadOutbox(fromFirstPage = false): void { + if (fromFirstPage) { + this.outboxPageIndex = 0; + } + this.loading = true; + this.error = ''; + this.i4c + .outbox({ + status: this.outboxStatus, + type: this.outboxType, + search: this.outboxSearch.trim(), + fromDate: this.outboxDates.fromDate, + toDate: this.outboxDates.toDate, + offset: this.outboxPageIndex * this.outboxPageSize, + limit: this.outboxPageSize + }) + .subscribe({ + next: (page) => { + this.outbox = page.pageItems; + this.outboxTotal = page.totalFilteredRecords; + this.loading = false; + }, + error: (e) => { + this.error = i4cErrorMessage(e); + this.loading = false; + } + }); + } + + loadLog(fromFirstPage = false): void { + if (fromFirstPage) { + this.logPageIndex = 0; + } + this.i4c + .apiLog({ + direction: this.direction, + failures: this.failuresOnly || null, + search: this.logSearch.trim(), + httpStatus: this.logHttpStatus, + responseCode: this.logResponseCode.trim(), + fromDate: this.logDates.fromDate, + toDate: this.logDates.toDate, + offset: this.logPageIndex * this.logPageSize, + limit: this.logPageSize + }) + .subscribe({ + next: (page) => { + this.log = page.pageItems; + this.logTotal = page.totalFilteredRecords; + }, + error: (e) => (this.error = i4cErrorMessage(e)) + }); + } + + retry(row: any): void { + this.busyId = row.id; + this.i4c.retryOutbox(row.id).subscribe({ + next: (r) => { + this.busyId = null; + this.snackBar.open(r?.sent ? 'Delivered to I4C.' : 'Queued; it will be sent again shortly.', 'OK', { + duration: 4000 + }); + this.loadOutbox(); + this.loadLog(); + }, + error: (e) => { + this.busyId = null; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + } + }); + } + + openLogModal(row: any): void { + this.selectedLogModal = { ...row }; + this.logModalTab = 'req'; + this.i4c.apiLogEntry(row.id).subscribe({ + next: (entry) => (this.selectedLogModal = entry), + error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }) + }); + } + + closeLogModal(): void { + this.selectedLogModal = null; + } + + openOutboxModal(row: any): void { + this.selectedOutboxModal = row; + } + + closeOutboxModal(): void { + this.selectedOutboxModal = null; + } + + copyPayload(text: string, key: string): void { + if (!text) { + return; + } + navigator.clipboard.writeText(text); + this.copiedKey = key; + this.snackBar.open('Payload copied to clipboard.', 'OK', { duration: 2500 }); + setTimeout(() => { + if (this.copiedKey === key) { + this.copiedKey = ''; + } + }, 2000); + } + + /** A call failed when HTTP says so or I4C/the bank answered with a code other than 00. */ + isFailedCall(log: any): boolean { + return (log?.http_status ?? 0) >= 400 || (!!log?.response_code && log.response_code !== '00'); + } + + /** Successful calls keep a short note in the error column, e.g. how I4C logged in; spell it out. */ + logNote(text: string): string { + const modes: Record = { + TOKEN: 'I4C logged in with a Bearer token', + BASIC: 'I4C logged in with Basic authentication', + BODY: 'I4C sent its username and password in the request body' + }; + return (text || '').replace(/auth (TOKEN|BASIC|BODY)/g, (m, mode) => modes[mode] ?? m); + } + + pretty(text: string): string { + if (!text) { + return ''; + } + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch { + return text; + } + } + + ok(status: number | null): boolean { + return status !== null && status !== undefined && status >= 200 && status < 300; + } +} diff --git a/src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.html b/src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.html new file mode 100644 index 000000000..22cd0265b --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.html @@ -0,0 +1,334 @@ + + +
+ +
+ All Complaints + + @if (data) { +
+ Ack No: + {{ data.acknowledgement_no }} + +
+ } + + + + @if (canReprocess) { + + } + +
+ + @if (loading || busy) { + + } + @if (error) { +
+ + {{ error }} +
+ } + + @if (data) { + +
+ +
+
+ + Complaint Details (From I4C) +
+
+
+
Acknowledgement No.
+
+ {{ data.acknowledgement_no }} +
+ +
CBS Internal Job ID
+
{{ data.job_id }}
+ +
Sub-Category
+
+ {{ data.sub_category }} +
+ +
Account in this Bank
+
+ {{ data.payer_account_number }} +
+ +
Complainant Bank
+
{{ data.payer_bank }} {{ data.payer_bank_code ? '(' + data.payer_bank_code + ')' : '' }}
+ +
Payment Mode / Type
+
{{ data.mode_of_payment || '-' }} {{ data.transaction_type ? '/ ' + data.transaction_type : '' }}
+ +
Wallet Reference
+
{{ data.wallet || 'None' }}
+ +
State & District
+
{{ data.state }} / {{ data.district }}
+ +
Received At
+
{{ data.received_at | date: 'dd MMM yyyy, HH:mm:ss' }} from {{ data.source_ip }}
+
+
+
+ + +
+
+ + Automation & Processing Outcome +
+
+
+
Processing Status
+
+ {{ data.status }} +
+ +
Processing Attempts
+
{{ data.processing_attempts }}
+ +
Processed On
+
{{ data.processed_at ? (data.processed_at | date: 'dd MMM yyyy, HH:mm:ss') : 'Not yet processed' }}
+ +
Total Amount Held
+
+ ₹{{ data.amount_held | number: '1.2-2' }} +
+ +
Outbox Response Status
+
+ {{ data.response_status || 'NOT YET SENT' }} +
+ + @if (data.processing_error) { +
Last Processing Error
+
{{ data.processing_error }}
+ } +
+
+
+
+ + +
+
+ + Reported Incidents & CBS Matching Engine Outcomes +
+
+ + + + + + + + + + + + + + + + + @for (inc of data.incidents; track inc.id) { + + + + + + + + + + + + + } + +
#RRNDate & TimeAmount (₹)Disputed (₹)LayerMatch MethodOutcomeHeld AmountRemarks
+ {{ inc.seq_no }} + + {{ inc.rrn }} + {{ inc.transaction_date }} {{ inc.transaction_time }}{{ inc.amount | number: '1.2-2' }}{{ inc.disputed_amount | number: '1.2-2' }}{{ inc.layer }} + {{ methodLabel(inc.match_method) }} + @if (inc.matched_txn_id) { +
Txn #{{ inc.matched_txn_id }}
+ } +
+ {{ inc.outcome || 'PENDING' }} +
Code: {{ inc.status_code || '-' }}
+
+ @if (holdFor(inc.id); as hold) { + ₹{{ hold.amount_held | number: '1.2-2' }} + @if (hold.shortfall > 0) { +
Short: ₹{{ hold.shortfall | number: '1.2-2' }}
+ } + } @else { + - + } +
{{ inc.remarks || '-' }}
+
+
+ + +
+
+ + Entries Reported to I4C (Section 6 Compliance) +
+
+ + + + + + + + + + + + + @for (f of data.findings; track f.id) { + + + + + + + + + @if (openEntry === f.id) { + + + + } + } @empty { + + + + } + +
IncidentTransaction TypeAmount (₹)Disputed (₹)Status CodeActions
+ {{ f.incident_id }} + {{ f.txn_type }} ({{ f.txn_type_id }})₹{{ f.amount | number: '1.2-2' }}₹{{ f.disputed_amount | number: '1.2-2' }} + {{ f.status_code }} + + +
+
+
+ Finding #{{ f.id }} Payload + +
+
{{ pretty(f.entry_json) }}
+
+
No section 6 findings reported yet.
+
+
+ + +
+
+ + Delivery to I4C Outbox Queue +
+
+ + + + + + + + + + + + + + @for (o of data.outbox; track o.id) { + + + + + + + + + + } @empty { + + + + } + +
Created AtStatusAttemptsHTTP CodeSent TimestampError
{{ o.created_at | date: 'dd MMM, HH:mm:ss' }} + {{ o.status }} + {{ o.attempts }} + {{ o.http_status ?? '-' }} + {{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}{{ o.last_error || '-' }} + @if (o.status === 'FAILED') { + + } +
No response written to outbox yet.
+
+
+ + +
+
+ + Cryptographic & Raw Payloads Inspection + + +
+ @if (showRaw) { +
+
+
Received from I4C Gateway (Sensitive credentials and PAN masked)
+
{{ pretty(data.raw_payload) }}
+
+ @for (o of data.outbox; track o.id) { +
+
Response #{{ o.id }} Sent to I4C
+
{{ pretty(o.payload) }}
+
+ } +
+ } +
+ } +
diff --git a/src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.scss b/src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.scss new file mode 100644 index 000000000..96ae5c45a --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.scss @@ -0,0 +1,69 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use '../i4c' as *; + +@include i4c-skin; + +.ack-badge-strip { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 9999px; + background: #f1f5f9; + border: 1px solid #e2e8f0; + + .label { + font-size: 10px; + font-weight: 600; + color: #64748b; + } + + .val { + font-size: 12px; + color: #0f172a; + } +} + +.text-accent { + color: #2563eb; +} + +.cat-chip { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + background: #f1f5f9; + font-size: 10px; + color: #475569; +} + +.sub { + font-size: 10.5px; + color: #64748b; + margin-top: 1px; +} + +.sub-title { + font-size: 11.5px; + font-weight: 600; + color: #475569; + margin-bottom: 4px; +} + +.err-text { + color: #dc2626; + font-size: 11px; +} + +.payloads { + display: flex; + flex-direction: column; + gap: 12px; +} 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 new file mode 100644 index 000000000..8ac0ee4f2 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-request-detail/i4c-request-detail.component.ts @@ -0,0 +1,166 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { DatePipe, DecimalPipe } from '@angular/common'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { I4cService, i4cErrorMessage } from '../i4c.service'; + +/** + * One complaint from I4C end to end: what I4C sent, what the bank matched in its books, the holds it placed, every + * entry it reported back, and each attempt to deliver that report. + */ +@Component({ + selector: 'mifosx-i4c-request-detail', + templateUrl: './i4c-request-detail.component.html', + styleUrls: ['./i4c-request-detail.component.scss'], + imports: [ + DatePipe, + DecimalPipe, + RouterLink, + MatButtonModule, + MatProgressBarModule, + FaIconComponent + ] +}) +export class I4cRequestDetailComponent implements OnInit { + private i4c = inject(I4cService); + private route = inject(ActivatedRoute); + private snackBar = inject(MatSnackBar); + + data: any = null; + loading = false; + busy = false; + error = ''; + showRaw = false; + openEntry: number | null = null; + + ngOnInit(): void { + this.load(); + } + + get id(): number { + return Number(this.route.snapshot.paramMap.get('id')); + } + + load(): void { + this.loading = true; + this.error = ''; + this.i4c.request(this.id).subscribe({ + next: (d) => { + this.data = d; + this.loading = false; + }, + error: (e) => { + this.error = i4cErrorMessage(e); + this.loading = false; + } + }); + } + + get canReprocess(): boolean { + return this.data?.status === 'RETRY' || this.data?.status === 'RECEIVED'; + } + + reprocess(): void { + this.busy = true; + this.i4c.reprocess(this.id).subscribe({ + next: (d) => { + this.data = d; + this.busy = false; + this.snackBar.open('Complaint processed again.', 'OK', { duration: 3000 }); + }, + error: (e) => { + this.busy = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + } + }); + } + + retry(outboxId: number): void { + this.busy = true; + this.i4c.retryOutbox(outboxId).subscribe({ + next: (r) => { + this.busy = false; + this.snackBar.open( + r?.sent ? 'Response delivered to I4C.' : 'Retry queued; it will be sent again shortly.', + 'OK', + { + duration: 4000 + } + ); + this.load(); + }, + error: (e) => { + this.busy = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + } + }); + } + + findingsFor(incidentId: number): any[] { + return (this.data?.findings || []).filter((f: any) => f.incident_id === incidentId); + } + + holdFor(incidentId: number): any { + return (this.data?.holds || []).find((h: any) => h.incident_id === incidentId); + } + + pretty(json: string): string { + try { + return JSON.stringify(JSON.parse(json), null, 2); + } catch { + return json; + } + } + + outcomeClass(outcome: string): string { + switch (outcome) { + case 'HELD': + case 'TRACED': + return 'i4c-badge--green'; + case 'PARTIALLY_HELD': + case 'PROSPECTIVE_HOLD': + return 'i4c-badge--amber'; + case 'NOT_FOUND': + case 'NO_MATCH': + case 'NOT_HOLDABLE': + case 'ERROR': + return 'i4c-badge--red'; + default: + return ''; + } + } + + methodLabel(method: string): string { + switch (method) { + case 'RRN_FUND_TRANSFER': + return 'RRN on outward transfer'; + case 'RRN_REFERENCE_TEXT': + return 'RRN in transaction narration'; + case 'AMOUNT_DATE': + return 'Amount + date (no RRN in CBS)'; + case 'NONE': + return 'Not matched'; + default: + return method || '-'; + } + } + + copyAck(text: string): void { + if (!text) { + return; + } + navigator.clipboard.writeText(text); + this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 }); + } +} diff --git a/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.html b/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.html new file mode 100644 index 000000000..345ee675e --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.html @@ -0,0 +1,566 @@ + + +
+ +
+
+ + @for (s of statuses; track s) { + + } +
+ + + + + Search acknowledgement / account / job id + + + + + + {{ total }} records +
+ +
+ + Sub-category + + + + Layer + + All layers + Layer 0 (victim's account) + Layer 1+ (money landed here) + + + + Response to I4C + + Any + Sent + Pending / sending + Failed + Not yet created + + + + +
+ + @if (loading) { + + } + @if (error) { +
+ + {{ error }} +
+ } + + +
+
+ + I4C-MHA Cyber Fraud Complaints Log + Click any complaint to open modern detail popup window +
+ +
+ + + + + + + + + + + + + + + + @for (row of rows; track row.id) { + + + + + + + + + + + + } @empty { + + + + } + +
ReceivedAcknowledgement No.Sub-CategoryTarget AccountLayerIncidentsHeld (₹)StatusI4C Response
+ {{ row.received_at | date: 'dd MMM yyyy' }} +
{{ row.received_at | date: 'HH:mm:ss' }}
+
+
+ {{ row.acknowledgement_no }} + +
+
Job: {{ row.job_id }}
+
+ {{ row.sub_category || 'N/A' }} + + {{ row.payer_account_number }} + {{ row.max_layer }}{{ row.incidents }}₹{{ row.amount_held | number: '1.2-2' }} + {{ row.status }} + @if (row.processing_error) { +
{{ row.processing_error }}
+ } +
+ + {{ row.response_status || 'NOT YET' }} + +
+
+
No complaints from I4C matched your query.
+
+
+ +
+ + + @if (selectedComplaint) { +
+
+ +
+
+ +
+ +
+ Job ID: {{ selectedComplaint.job_id }} • Received: + {{ selectedComplaint.received_at | date: 'dd MMM yyyy, HH:mm:ss' }} +
+
+
+
+ +
+
+ + +
+ + + + + +
+ + +
+ @if (detailLoading) { + + } + @if (detailError) { +
{{ detailError }}
+ } + + + @if (modalTab === 'overview') { +
+
+
Target Customer Account
+
+
+
Account Number
+
+ {{ selectedComplaint.payer_account_number }} +
+ +
Sub-Category
+
{{ selectedComplaint.sub_category }}
+ +
Complainant Bank
+
+ {{ selectedComplaint.payer_bank }} + {{ selectedComplaint.payer_bank_code ? '(' + selectedComplaint.payer_bank_code + ')' : '' }} +
+ +
Payment Mode
+
+ {{ selectedComplaint.mode_of_payment || 'N/A' }} + {{ selectedComplaint.transaction_type ? '/ ' + selectedComplaint.transaction_type : '' }} +
+ +
Wallet Reference
+
{{ selectedComplaint.wallet || 'None' }}
+ +
State / District
+
{{ selectedComplaint.state }} / {{ selectedComplaint.district }}
+ +
Source IP
+
{{ selectedComplaint.source_ip || '-' }}
+
+
+
+ +
+
Processing Status
+
+
+
Processing Status
+
+ {{ + selectedComplaint.status + }} +
+ +
Processing Attempts
+
{{ selectedComplaint.processing_attempts ?? 1 }}
+ +
Processed Timestamp
+
+ {{ + selectedComplaint.processed_at + ? (selectedComplaint.processed_at | date: 'dd MMM yyyy, HH:mm:ss') + : 'Pending processing' + }} +
+ +
Total Held Amount
+
+ ₹{{ selectedComplaint.amount_held | number: '1.2-2' }} +
+ +
Response to I4C
+
+ + {{ selectedComplaint.response_status || 'NOT YET SENT' }} + +
+ + @if (selectedComplaint.processing_error) { +
Last Error
+
{{ selectedComplaint.processing_error }}
+ } +
+
+
+
+ } + + + @if (modalTab === 'incidents') { +
+
+ + + + + + + + + + + + + + + + @for (inc of selectedComplaint.incidents; track inc.id) { + + + + + + + + + + + + } @empty { + + + + } + +
#RRNDateAmount (₹)Disputed (₹)LayerMatched ByOutcomeHeld (₹)
+ {{ inc.seq_no }} + {{ inc.rrn }}{{ inc.transaction_date }} {{ inc.transaction_time }}{{ inc.amount | number: '1.2-2' }}{{ inc.disputed_amount | number: '1.2-2' }}{{ inc.layer }} + {{ inc.match_method || 'EXACT' }} + @if (inc.matched_txn_id) { +
Txn #{{ inc.matched_txn_id }}
+ } +
+ {{ + inc.outcome || 'PENDING' + }} +
Code: {{ inc.status_code || '-' }}
+
+ @if (holdFor(inc.id); as hold) { + ₹{{ hold.amount_held | number: '1.2-2' }} + @if (hold.shortfall > 0) { +
Short ₹{{ hold.shortfall | number: '1.2-2' }}
+ } + } @else { + - + } +
No incident details found.
+
+
+ } + + + @if (modalTab === 'findings') { +
+
+ + + + + + + + + + + + + @for (f of selectedComplaint.findings; track f.id) { + + + + + @if (f.disputed_amount === null || f.disputed_amount === undefined) { + + } @else { + + } + + + + @if (openFindingJsonId === f.id) { + + + + } + } @empty { + + + + } + +
Incident IDTransaction TypeAmount (₹)Disputed (₹)Status CodeRaw JSON
{{ f.incident_id }}{{ f.txn_type }} ({{ f.txn_type_id }})₹{{ f.amount | number: '1.2-2' }} + - + ₹{{ f.disputed_amount | number: '1.2-2' }} + + {{ f.status_code }} · {{ statusCodeMeaning(f.status_code) }} + + + +
+
+
+ Finding #{{ f.id }} Payload + +
+
{{ prettyJson(f.entry_json) }}
+
+
No section 6 findings reported yet.
+
+
+ } + + + @if (modalTab === 'outbox') { +
+
+ + + + + + + + + + + + + + + @for (o of selectedComplaint.outbox; track o.id) { + + + + + + + + + + + } @empty { + + + + } + +
CreatedTypeStatusAttemptsHTTP CodeSent TimeErrorAction
{{ o.created_at | date: 'dd MMM, HH:mm:ss' }} + {{ + o.message_type === 'ACK' ? 'ACK' : 'RESPONSE' + }} + + {{ o.status }} + {{ o.attempts }}{{ o.http_status ?? '-' }}{{ o.sent_at ? (o.sent_at | date: 'dd MMM, HH:mm:ss') : '-' }}{{ o.last_error }} + @if (o.status === 'FAILED') { + + } +
No outbox delivery records yet.
+
+
+ } + + + @if (modalTab === 'payloads') { +
+
+
+ + Payload Received from I4C Gateway (Masked) + +
+
{{ prettyJson(selectedComplaint.raw_payload) }}
+
+ + @for (o of selectedComplaint.outbox; track o.id) { +
+
+ + Response #{{ o.id }} Delivered to I4C + +
+
{{ prettyJson(o.payload) }}
+
+ } +
+ } +
+ + + +
+
+ } +
diff --git a/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.scss b/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.scss new file mode 100644 index 000000000..e316736c0 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.scss @@ -0,0 +1,180 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use '../i4c' as *; + +@include i4c-skin; + +.search-input { + width: 240px; +} + +.search-btn { + height: 38px; + border-radius: 8px; + font-weight: 600; + font-size: 11.5px; +} + +.count-tag { + font-size: 11px; + font-weight: 700; + color: #64748b; + background: #f1f5f9; + padding: 2px 7px; + border-radius: 9999px; + border: 1px solid #e2e8f0; +} + +.head-hint { + margin-left: auto; + font-size: 11px; + color: #94a3b8; + font-weight: normal; +} + +.ack-cell { + display: flex; + align-items: center; + gap: 6px; + + .ack-no { + font-weight: 700; + color: #0f172a; + font-size: 12px; + } +} + +.date-main { + font-weight: 600; + color: #1e293b; +} + +.cat-pill { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + background: #f1f5f9; + font-size: 10px; + color: #475569; +} + +.modal-title-row { + display: flex; + align-items: center; + gap: 8px; +} + +.sub { + font-size: 10.5px; + color: #64748b; + margin-top: 1px; +} + +.err-text { + color: #dc2626; + font-size: 10.5px; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.amount-accent { + color: #0f172a; + font-size: 13px; +} + +.payloads-view { + display: flex; + flex-direction: column; + gap: 12px; +} + +.payload-box { + display: flex; + flex-direction: column; + gap: 6px; + + &__title { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: #1e293b; + + fa-icon { + color: #2563eb; + } + } +} + +.footer-btn-close { + padding: 6px 14px; + border-radius: 8px; + background: #e2e8f0; + color: #334155; + border: none; + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #cbd5e1; + color: #0f172a; + } +} + +.finding-json-cell { + padding: 12px 16px !important; + background: #0b1329 !important; + border-bottom: 2px solid #1e293b !important; +} + +.finding-json-box { + display: flex; + flex-direction: column; + gap: 8px; + + .finding-json-head { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + font-weight: 600; + color: #94a3b8; + padding-bottom: 6px; + border-bottom: 1px solid #1e293b; + + fa-icon { + color: #60a5fa; + } + } + + /* Shown at full length: the popup body is the one scroll area, so nothing hides behind the footer. */ + pre.i4c-json { + max-height: none; + overflow: visible; + border: none; + background: transparent; + padding: 6px 0 16px; + box-shadow: none; + white-space: pre-wrap; + word-break: break-all; + font-size: 11px; + line-height: 1.6; + color: #38bdf8; + } +} + +.muted-cell { + color: #94a3b8; + cursor: help; +} diff --git a/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.ts b/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.ts new file mode 100644 index 000000000..99889058a --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-requests/i4c-requests.component.ts @@ -0,0 +1,320 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { DatePipe, DecimalPipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { I4cService, i4cErrorMessage } from '../i4c.service'; +import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component'; + +/** Every complaint I4C has pushed to the bank, newest first. */ +@Component({ + selector: 'mifosx-i4c-requests', + templateUrl: './i4c-requests.component.html', + styleUrls: ['./i4c-requests.component.scss'], + imports: [ + DatePipe, + DecimalPipe, + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatProgressBarModule, + MatPaginatorModule, + I4cDateRangeComponent, + FaIconComponent + ] +}) +export class I4cRequestsComponent implements OnInit { + private i4c = inject(I4cService); + private route = inject(ActivatedRoute); + private router = inject(Router); + private snackBar = inject(MatSnackBar); + + readonly statuses = [ + 'RECEIVED', + 'PROCESSING', + 'RETRY', + 'PROCESSED' + ]; + rows: any[] = []; + status = ''; + search = ''; + subCategory = ''; + layer = ''; + response = ''; + dates: I4cDateRange = { ...ALL_DATES }; + total = 0; + pageIndex = 0; + pageSize = 20; + readonly pageSizes = [ + 10, + 20, + 50, + 100 + ]; + loading = false; + error = ''; + + // Popup Window Modal state + selectedComplaint: any = null; + detailLoading = false; + detailError = ''; + modalTab: 'overview' | 'incidents' | 'findings' | 'outbox' | 'payloads' = 'overview'; + openFindingJsonId: number | null = null; + reprocessing = false; + retryingOutboxId: number | null = null; + copiedId = ''; + + ngOnInit(): void { + this.status = this.route.snapshot.queryParamMap.get('status') || ''; + this.load(); + } + + /** Reload the current page; a changed filter passes `fromFirstPage` so the officer lands on page one. */ + load(fromFirstPage = false): void { + if (fromFirstPage) { + this.pageIndex = 0; + } + this.loading = true; + this.error = ''; + this.i4c + .requests({ + status: this.status, + search: this.search.trim(), + subCategory: this.subCategory.trim(), + layer: this.layer, + response: this.response, + fromDate: this.dates.fromDate, + toDate: this.dates.toDate, + offset: this.pageIndex * this.pageSize, + limit: this.pageSize + }) + .subscribe({ + next: (page) => { + this.rows = page.pageItems; + this.total = page.totalFilteredRecords; + this.loading = false; + }, + error: (e) => { + this.error = i4cErrorMessage(e); + this.loading = false; + } + }); + } + + setStatus(s: string): void { + this.status = s; + this.load(true); + } + + onPage(e: PageEvent): void { + this.pageIndex = e.pageIndex; + this.pageSize = e.pageSize; + this.load(); + } + + onDates(range: I4cDateRange): void { + this.dates = range; + this.load(true); + } + + get hasFilters(): boolean { + return !!( + this.status || + this.search || + this.subCategory || + this.layer || + this.response || + this.dates.preset !== 'ALL' + ); + } + + clearFilters(): void { + this.status = ''; + this.search = ''; + this.subCategory = ''; + this.layer = ''; + this.response = ''; + this.dates = { ...ALL_DATES }; + this.load(true); + } + + // Open in Modern Popup Window Modal + openModal(row: any): void { + this.detailLoading = true; + this.detailError = ''; + this.modalTab = 'overview'; + this.openFindingJsonId = null; + this.selectedComplaint = { ...row }; + + this.i4c.request(row.id).subscribe({ + next: (full) => { + // Keep the list figures (held amount, response status) if the detail lacks them. + this.selectedComplaint = { ...row, ...full }; + this.detailLoading = false; + }, + error: (e) => { + this.detailError = i4cErrorMessage(e); + this.detailLoading = false; + } + }); + } + + closeModal(): void { + this.selectedComplaint = null; + } + + toggleFindingJson(id: number): void { + this.openFindingJsonId = this.openFindingJsonId === id ? null : id; + } + + // Open in Full Page view + openFullPage(id: number): void { + this.closeModal(); + this.router.navigate([id], { relativeTo: this.route }); + } + + reprocessModal(id: number): void { + this.reprocessing = true; + this.i4c.reprocess(id).subscribe({ + next: () => { + this.reprocessing = false; + this.snackBar.open('Complaint queued for reprocessing.', 'OK', { duration: 4000 }); + this.openModal({ id }); + this.load(); + }, + error: (e) => { + this.reprocessing = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + } + }); + } + + retryOutboxItem(id: number): void { + this.retryingOutboxId = id; + this.i4c.retryOutbox(id).subscribe({ + next: (r) => { + this.retryingOutboxId = null; + this.snackBar.open(r?.sent ? 'Delivered to I4C.' : 'Queued for retry.', 'OK', { duration: 4000 }); + if (this.selectedComplaint?.id) { + this.openModal({ id: this.selectedComplaint.id }); + } + }, + error: (e) => { + this.retryingOutboxId = null; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }); + } + }); + } + + copyAck(text: string, id: string, event?: Event): void { + if (event) { + event.stopPropagation(); + } + if (!text) { + return; + } + navigator.clipboard.writeText(text); + this.copiedId = id; + this.snackBar.open(`Copied: ${text}`, 'OK', { duration: 2500 }); + setTimeout(() => { + if (this.copiedId === id) { + this.copiedId = ''; + } + }, 2000); + } + + /** What a section 6 status_code means, as I4C reads it. */ + statusCodeMeaning(code: string): string { + switch (code) { + case '00': + return 'Success'; + case '01': + return 'Record not found'; + case '02': + return 'Invalid RRN'; + case '70010': + return 'Not processed'; + default: + return code ? 'Code ' + code : 'No code'; + } + } + + statusCodeClass(code: string): string { + return code === '00' ? 'i4c-badge--green' : code === '01' || code === '02' ? 'i4c-badge--amber' : 'i4c-badge--red'; + } + + prettyJson(val: any): string { + if (!val) { + return ''; + } + try { + const parsed = typeof val === 'string' ? JSON.parse(val) : val; + return JSON.stringify(parsed, null, 2); + } catch { + return String(val); + } + } + + statusClass(status: string): string { + switch (status) { + case 'PROCESSED': + return 'i4c-badge--green'; + case 'RETRY': + return 'i4c-badge--red'; + case 'PROCESSING': + case 'RECEIVED': + return 'i4c-badge--amber'; + default: + return ''; + } + } + + responseClass(status: string): string { + switch (status) { + case 'SENT': + return 'i4c-badge--green'; + case 'FAILED': + return 'i4c-badge--red'; + case 'PENDING': + case 'SENDING': + return 'i4c-badge--amber'; + default: + return ''; + } + } + + outcomeClass(outcome: string): string { + switch (outcome) { + case 'HELD': + return 'i4c-badge--green'; + case 'SHORT': + return 'i4c-badge--amber'; + case 'NOT_FOUND': + case 'FAILED': + return 'i4c-badge--red'; + default: + return ''; + } + } + + holdFor(incidentId: number): any { + return (this.selectedComplaint?.holds || []).find((h: any) => h.incident_id === incidentId); + } +} diff --git a/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.html b/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.html new file mode 100644 index 000000000..b2f6ce3a6 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.html @@ -0,0 +1,682 @@ + + +
+
+
+ + +
+ + + Layer 0 ack no. + + + + {{ total }} records +
+ +
+ + MR id / ack / victim / account / UTR + + + + Status + + All open + All + @for (s of statuses; track s.value) { + {{ s.label }} + } + + + @if (role === 'BENEFICIARY_BANK') { + + Bank action + + Any + Pending + @for (a of bankActions; track a.value) { + {{ a.label }} + } + + + } + + +
+ + @if (loading) { + + } + @if (error) { +
+ {{ error }} +
+ } + +
+
+ + MRM Money Restoration + Assess a hold for MRM from the hold (Holds Register → hold → GRM / MRM). Click a row to work on it. +
+
+ + + + + + @if (role === 'BENEFICIARY_BANK') { + + + + } + + + + + + + @for (r of rows; track r.id) { + + + + @if (role === 'BENEFICIARY_BANK') { + + + + } + + + + + } @empty { + + + + } + +
OpenedAck / MR idAccount on holdBasisRestorable (₹)VictimStatusMoney
+ {{ r.created_at | date: 'dd MMM yyyy' }} +
{{ r.created_by }}
+
+ {{ r.acknowledgement_no }} +
{{ r.mrm_request_no || 'no MR id yet' }}
+
+ {{ r.hold_account_no }} +
{{ r.client_name }} · held ₹{{ r.amount_held | number: '1.2-2' }}
+
{{ label(bases, r.basis) }}{{ r.restorable_amount | number: '1.2-2' }} + {{ r.victim_name || '-' }} +
{{ r.victim_account_no }} {{ r.victim_ifsc }}
+
+ {{ label(statuses, r.status) }} + @if (r.bank_action && r.bank_action !== 'PENDING') { +
{{ r.bank_action }}
+ } + @if (r.transfer_error) { +
{{ r.transfer_error }}
+ } +
+ @if (r.role === 'VICTIM_BANK') { + {{ + r.received_amount + ? '₹' + (r.received_amount | number: '1.2-2') + ' received' + : r.victim_status || '-' + }} + } @else { + {{ r.restored_amount ? '₹' + (r.restored_amount | number: '1.2-2') : '-' }} +
{{ r.utr || r.transfer_status || '' }}{{ r.mrm_updated ? ' · on MRM' : '' }}
+ } +
+ No restorations match these filters. +
+
+ +
+ + @if (selected; as r) { +
+
+
+
+ +
+ +
+ @if (isVictim) { + Our customer's account {{ r.victim_account_no }} + } @else { + Held on {{ r.hold_account_no }} ({{ r.client_name }}): ₹{{ r.amount_held | number: '1.2-2' }} · + disputed ₹{{ r.disputed_amount | number: '1.2-2' }} + } +
+
+
+
+ +
+
+ +
+ + + +
+ +
+ @if (tab === 'workflow') { +
+
+ @if (!isVictim) { + @if (r.status === 'ASSESSED' || r.status === 'REQUESTED') { +
+
1. Assessment (what the bank updates on MRM)
+
+ + Restoration + + @for (b of bases; track b.value) { + {{ b.label }} + } + + + @if (possible) { + + Restorable amount (₹) + + + + } +
+ @if (proRata) { + + + + + + + + + + + @for (h of proRata.holds; track h.id) { + + + + + + + } + +
Complaint (ack)DisputedHeldPro-rata share
{{ h.acknowledgement_no }}{{ h.id === r.hold_id ? ' (this one)' : '' }}{{ h.disputed_amount | number: '1.2-2' }}{{ h.amount_held | number: '1.2-2' }} + {{ h.share | number: '1.2-2' }} +
+
+ ₹{{ proRata.amount | number: '1.2-2' }} held on {{ proRata.accountNo }} split by disputed + amount (total ₹{{ proRata.totalDisputed | number: '1.2-2' }}). +
+ } + + Remarks{{ possible ? '' : ' (why not possible) *' }} + + +
+ + @if (r.status === 'ASSESSED' && !r.mrm_updated) { + + } +
+
+ } + + @if (r.status === 'ASSESSED' || r.status === 'REQUESTED' || r.status === 'IN_POOL') { +
+
+ {{ + r.status === 'IN_POOL' + ? 'Refund account (correct it if the victim bank referred back)' + : "2. Victim's MRM request, as the IO sent it" + }} +
+
+ @if (r.status !== 'IN_POOL') { + + MRM request id * + + + } + + Victim name * + + + + Refund account no. * + + + + IFSC (blank = this bank) + + + + Victim's bank + + + @if (request['victimIfsc']) { + + Send by + + NEFT + IMPS + RTGS + + + } +
+ @if (r.status !== 'IN_POOL') { +
+ + IO name + + + + Police station + + + + BNSS 106 notice no. + + + + Notice date + + + Indemnity bond received (Rs 10 stamp paper) +
+
+ + FIR no. (needed above ₹{{ r.firThreshold }}) + + + + FIR date + + + + Court order no. + + + + Court order date + + + + Court + + +
+ } +
+ +
+
+ } + + @if (r.status === 'REQUESTED') { +
+
3. Bank action (as on MRM)
+
+ + Action + + @for (a of bankActions; track a.value) { + {{ a.label }} + } + + + @if (action.action === 'REFER_BACK_LEA') { + + Reason + + @for (x of referBackReasons; track x.value) { + {{ x.label }} + } + + + } @else { + + Remarks{{ action.action === 'COMPLIED' ? '' : ' *' }} + + + } +
+ @if (action.action === 'COMPLIED') { + @if (papers.length) { +
Still needed before compliance: {{ papers.join(', ') }}.
+ } +
+ ₹{{ r.restorable_amount | number: '1.2-2' }} goes to {{ r.victim_name }} ({{ + r.victim_account_no + }}{{ r.victim_ifsc ? ', ' + r.victim_ifsc : ', this bank' }}) once a second officer approves. + @if (r.victim_ifsc && !r.poolConfigured) { + No restoration pool account is configured, so an outward restoration cannot be + approved. + } +
+ } @else if (action.action === 'NOT_COMPLIED' || action.action === 'REFER_BACK_LEA') { +
+ MRM asks for the account's bank statement with this action; attach it on the Documents tab. +
+ } +
+ +
+
+ } + + @if (r.status === 'AWAITING_APPROVAL') { +
+ Restoration request #{{ r.action_request_id }} is waiting for a second officer on the Approvals + page. On approval ₹{{ r.restorable_amount | number: '1.2-2' }} leaves {{ r.hold_account_no }} at + once. +
+ } + + @if (r.status === 'IN_POOL') { +
+
+ ₹{{ r.restored_amount | number: '1.2-2' }} is in the restoration pool +
+
+ 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 : '.' + }} +
+
+ +
+
+ } + + @if (r.status === 'TRANSFER_PENDING') { +
+ Outward transfer {{ r.fund_transfer_ref }} is {{ r.transfer_status }} (fund-transfer approval / + NEFT batch). The I4C Hold Review job marks it restored once it settles. +
+ } + + @if (r.status === 'RESTORED') { +
+
+ Restored ₹{{ r.restored_amount | number: '1.2-2' }} on + {{ r.restored_at | date: 'dd MMM yyyy, HH:mm' }} +
+
+ On MRM, choose "Compliance of restoration order" and enter amount ₹{{ + r.restored_amount | number: '1.2-2' + }}, transaction id {{ r.utr }} and this date and time. +
+
+ +
+
+ } + } @else { + @if (r.status !== 'CLOSED') { +
+
Restored money received for our customer
+
+ + Amount (₹) * + + + + UTR / transaction id * + + + + Credited on * + + + + Credited to account + + +
+
+ + @if (r.status === 'RECEIVED') { + + } +
+
+
+
Or: why no (more) restoration is expected
+
+ + Victim status + + @for (v of victimStatuses; track v.value) { + {{ v.label }} + } + + + + Justification * + + +
+
+ +
+
+ } @else { +
+ Case closed{{ r.victim_status ? ': ' + label(victimStatuses, r.victim_status) : '' }}. +
+ } + } + @if (!isVictim && r.status === 'CLOSED') { +
+ Closed{{ + r.bank_action === 'NOT_COMPLIED' ? ': order not complied (' + r.bank_action_reason + ')' : '' + }}. +
+ } +
+
+ } + + @if (tab === 'details') { +
+
+
Request & papers
+
+
+
MR id
+
{{ r.mrm_request_no || '-' }}
+
Basis
+
{{ label(bases, r.basis) }} {{ r.basis_remarks ? '(' + r.basis_remarks + ')' : '' }}
+
Restorable
+
{{ r.restorable_amount | number: '1.2-2' }}
+
Victim
+
+ {{ r.victim_name }} · {{ r.victim_account_no }} {{ r.victim_ifsc }} {{ r.victim_bank_name }} +
+
IO
+
{{ r.io_name || '-' }} {{ r.io_police_station }}
+
BNSS 106
+
{{ r.bnss_notice_no || '-' }} {{ r.bnss_notice_date | date: 'dd MMM yyyy' }}
+
Indemnity bond
+
{{ r.indemnity_bond_received ? 'Received' : 'Not received' }}
+
FIR
+
{{ r.fir_no || '-' }} {{ r.fir_date | date: 'dd MMM yyyy' }}
+
Court order
+
+ {{ r.court_order_no || '-' }} {{ r.court_order_date | date: 'dd MMM yyyy' }} {{ r.court_name }} +
+
+
+
+
+
Money
+
+
+
Bank action
+
{{ r.bank_action }} {{ r.bank_action_reason ? '(' + r.bank_action_reason + ')' : '' }}
+
Approval
+
+ @if (r.actionRequest?.length) { + #{{ r.actionRequest[0].id }} {{ r.actionRequest[0].status }} + {{ r.actionRequest[0].checker_name || '' }} + {{ r.actionRequest[0].failure_message || r.actionRequest[0].rejection_reason || '' }} + } @else { + - + } +
+
Channel
+
{{ r.transfer_channel || '-' }} {{ r.transfer_status || '' }}
+
Pool transfer
+
{{ r.pool_transfer_id || '-' }}
+
Outward ref
+
{{ r.fund_transfer_ref || '-' }}
+
UTR / txn id
+
{{ r.utr || r.received_utr || '-' }}
+
Restored
+
+ {{ r.restored_amount ? (r.restored_amount | number: '1.2-2') : '-' }} + {{ r.restored_at | date: 'dd MMM yyyy, HH:mm' }} +
+
On MRM
+
+ {{ + r.mrm_updated + ? 'Yes, by ' + r.mrm_updated_by + ' ' + (r.mrm_updated_at | date: 'dd MMM yyyy') + : 'Not yet' + }} +
+
+
+
+
+ } + + @if (tab === 'documents') { +
+
+ +
+
+ } +
+ + +
+
+ } +
diff --git a/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.scss b/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.scss new file mode 100644 index 000000000..0fd035fa3 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.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; + +.highlight td { + background: #eff6ff; +} + +.err-text { + color: #dc2626; +} diff --git a/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.ts b/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.ts new file mode 100644 index 000000000..f60029726 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-restorations/i4c-restorations.component.ts @@ -0,0 +1,378 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { DatePipe, DecimalPipe } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { I4cService, i4cErrorMessage } from '../i4c.service'; +import { ALL_DATES, I4cDateRange, I4cDateRangeComponent } from '../i4c-date-range/i4c-date-range.component'; +import { I4cDocumentsComponent } from '../i4c-documents/i4c-documents.component'; + +export const MRM_BASES: { value: string; label: string }[] = [ + { value: 'ATTRIBUTION', label: 'Restoration possible (attribution)' }, + { value: 'PRO_RATA', label: 'Restoration possible (pro-rata distribution)' }, + { value: 'NOT_POSSIBLE_ALREADY_REFUNDED', label: 'Not possible (already refunded)' }, + { value: 'NOT_POSSIBLE_NO_BALANCE', label: 'Not possible (balance not available)' }, + { value: 'NOT_POSSIBLE_OTHERS', label: 'Not possible (others)' } +]; + +export const MRM_STATUSES: { value: string; label: string }[] = [ + { value: 'ASSESSED', label: 'Assessed' }, + { value: 'REQUESTED', label: 'Victim request received' }, + { value: 'AWAITING_APPROVAL', label: 'Awaiting checker' }, + { value: 'IN_POOL', label: 'In restoration pool' }, + { value: 'TRANSFER_PENDING', label: 'Outward transfer pending' }, + { value: 'RESTORED', label: 'Restored' }, + { value: 'AWAITED', label: 'Victim bank: awaited' }, + { value: 'RECEIVED', label: 'Victim bank: received' }, + { value: 'CLOSED', label: 'Closed' } +]; + +const BANK_ACTIONS = [ + { value: 'COMPLIED', label: 'Compliance of restoration order (restore the money)' }, + { value: 'NOT_COMPLIED', label: 'Restoration order not complied' }, + { value: 'REFER_BACK_LEA', label: 'Refer back to LEA' }, + { value: 'REFER_BACK_VICTIM_BANK', label: 'Refer back to victim bank (wrong refund account)' }, + { value: 'CONSENSUS', label: 'Consensus request (several holds on the account)' } +]; + +const REFER_BACK_REASONS = [ + { value: 'ISSUE_COURT_ORDER', label: 'Issue in court / police order' }, + { value: 'ISSUE_FIR', label: 'Issue in FIR details' }, + { value: 'ISSUE_ORDER_AND_FIR', label: 'Issue in order and FIR details' }, + { value: 'ISSUE_INDEMNITY_BOND', label: 'Issue in indemnity bond' } +]; + +const VICTIM_STATUSES = [ + { value: 'ALREADY_REFUNDED_BANK', label: 'Already refunded through the bank' }, + { value: 'ALREADY_REFUNDED_POLICE_NOTICE', label: 'Already refunded (police notice)' }, + { value: 'ALREADY_REFUNDED_COURT_ORDER', label: 'Already refunded (court order)' }, + { value: 'DECLINED_REFUND', label: 'Victim declined to receive the refund' }, + { value: 'DECLINED_TO_PURSUE', label: 'Victim declined to pursue the refund' }, + { value: 'RECEIVED_THROUGH_COURT', label: 'Victim received refund through court order' }, + { value: 'NOT_REACHABLE', label: 'Victim not reachable' } +]; + +/** + * MRM money restoration. As beneficiary bank: assess, record the victim's request and the IO's papers, take the bank + * action, and follow the money to the victim. As victim bank: record restored money received for our customer. + */ +@Component({ + selector: 'mifosx-i4c-restorations', + templateUrl: './i4c-restorations.component.html', + styleUrls: ['./i4c-restorations.component.scss'], + imports: [ + DatePipe, + DecimalPipe, + FormsModule, + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatProgressBarModule, + MatPaginatorModule, + FaIconComponent, + I4cDateRangeComponent, + I4cDocumentsComponent + ] +}) +export class I4cRestorationsComponent implements OnInit { + private i4c = inject(I4cService); + private route = inject(ActivatedRoute); + private snackBar = inject(MatSnackBar); + + readonly bases = MRM_BASES; + readonly statuses = MRM_STATUSES; + readonly bankActions = BANK_ACTIONS; + readonly referBackReasons = REFER_BACK_REASONS; + readonly victimStatuses = VICTIM_STATUSES; + readonly pageSizes = [ + 10, + 20, + 50, + 100 + ]; + + role = 'BENEFICIARY_BANK'; + status = 'OPEN'; + bankAction = ''; + search = ''; + dates: I4cDateRange = { ...ALL_DATES }; + rows: any[] = []; + total = 0; + pageIndex = 0; + pageSize = 20; + loading = false; + error = ''; + victimAck = ''; + + selected: any = null; + busy = false; + tab: 'workflow' | 'details' | 'documents' = 'workflow'; + assessment = { basis: 'ATTRIBUTION', restorableAmount: null as number | null, remarks: '' }; + request: Record = {}; + action = { action: 'COMPLIED', reason: '' }; + receipt = { amount: null as number | null, utr: '', receivedOn: '', accountNo: '' }; + victim = { status: 'ALREADY_REFUNDED_BANK', remarks: '' }; + proRata: any = null; + + ngOnInit(): void { + const open = Number(this.route.snapshot.queryParamMap.get('open')); + this.load(); + if (open) { + this.open(open); + } + } + + load(fromFirstPage = false): void { + if (fromFirstPage) { + this.pageIndex = 0; + } + this.loading = true; + this.error = ''; + this.i4c + .restorations({ + role: this.role, + status: this.status, + bankAction: this.bankAction, + search: this.search.trim(), + fromDate: this.dates.fromDate, + toDate: this.dates.toDate, + offset: this.pageIndex * this.pageSize, + limit: this.pageSize + }) + .subscribe({ + next: (page) => { + this.rows = page.pageItems; + this.total = page.totalFilteredRecords; + this.loading = false; + }, + error: (e) => { + this.error = i4cErrorMessage(e); + this.loading = false; + } + }); + } + + setRole(r: string): void { + this.role = r; + this.load(true); + } + + onPage(e: PageEvent): void { + this.pageIndex = e.pageIndex; + this.pageSize = e.pageSize; + this.load(); + } + + onDates(range: I4cDateRange): void { + this.dates = range; + this.load(true); + } + + get hasFilters(): boolean { + return !!(this.status !== 'OPEN' || this.bankAction || this.search || this.dates.preset !== 'ALL'); + } + + clearFilters(): void { + this.status = 'OPEN'; + this.bankAction = ''; + this.search = ''; + this.dates = { ...ALL_DATES }; + this.load(true); + } + + openVictimCase(): void { + if (!this.victimAck.trim()) { + return; + } + this.i4c.openVictimCase(this.victimAck.trim()).subscribe({ + next: (r) => { + this.victimAck = ''; + this.role = 'VICTIM_BANK'; + this.load(true); + this.open(r.resourceId); + }, + error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 7000 }) + }); + } + + open(id: number): void { + this.i4c.restoration(id).subscribe({ + next: (r) => { + const same = this.selected?.id === r.id; + this.selected = r; + if (!same) { + this.tab = 'workflow'; + this.proRata = null; + this.assessment = { + basis: r.basis || 'ATTRIBUTION', + restorableAmount: r.restorable_amount ?? r.amount_held, + remarks: r.basis_remarks || '' + }; + this.request = { + mrmRequestNo: r.mrm_request_no || '', + victimName: r.victim_name || '', + victimAccountNo: r.victim_account_no || '', + victimIfsc: r.victim_ifsc || '', + victimBankName: r.victim_bank_name || '', + transferChannel: r.transfer_channel && r.transfer_channel !== 'INTERNAL' ? r.transfer_channel : 'NEFT', + ioName: r.io_name || '', + ioPoliceStation: r.io_police_station || '', + firNo: r.fir_no || '', + firDate: r.fir_date || '', + courtOrderNo: r.court_order_no || '', + courtOrderDate: r.court_order_date || '', + courtName: r.court_name || '', + bnssNoticeNo: r.bnss_notice_no || '', + bnssNoticeDate: r.bnss_notice_date || '', + indemnityBondReceived: !!r.indemnity_bond_received + }; + this.action = { action: 'COMPLIED', reason: '' }; + this.receipt = { amount: null, utr: '', receivedOn: '', accountNo: r.victim_account_no || '' }; + this.victim = { status: 'ALREADY_REFUNDED_BANK', remarks: '' }; + } + }, + error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }) + }); + } + + close(): void { + this.selected = null; + } + + label(list: { value: string; label: string }[], v: string): string { + return list.find((x) => x.value === v)?.label || v || '-'; + } + + get isVictim(): boolean { + return this.selected?.role === 'VICTIM_BANK'; + } + + get possible(): boolean { + return this.assessment.basis === 'ATTRIBUTION' || this.assessment.basis === 'PRO_RATA'; + } + + /** Papers MRM needs for compliance, given what is recorded; mirrors the server's check. */ + get papers(): string[] { + const r = this.selected; + if (!r) { + return []; + } + const has = (t: string) => (r.documents || []).some((d: any) => d.doc_type === t); + const out: string[] = []; + if (r.court_order_no) { + if (!has('COURT_ORDER')) { + out.push('court order copy'); + } + } else { + if (!r.bnss_notice_no) { + out.push('BNSS 106 notice no.'); + } else if (!has('BNSS_106_NOTICE')) { + out.push('BNSS 106 notice copy'); + } + if (!r.indemnity_bond_received) { + out.push('indemnity bond received'); + } else if (!has('INDEMNITY_BOND')) { + out.push('indemnity bond copy'); + } + if (Number(r.restorable_amount) > Number(r.firThreshold)) { + if (!r.fir_no) { + out.push('FIR no. (above ₹' + r.firThreshold + ')'); + } else if (!has('FIR')) { + out.push('FIR copy'); + } + } + } + return out; + } + + private run(obs: any, done: string): void { + this.busy = true; + obs.subscribe({ + next: (r: any) => { + this.busy = false; + this.snackBar.open(done, 'OK', { duration: 5000 }); + this.open(r?.id ?? r?.resourceId ?? this.selected.id); + this.load(); + }, + error: (e: any) => { + this.busy = false; + this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 8000 }); + } + }); + } + + saveAssessment(): void { + this.run( + this.i4c.assessRestoration(this.selected.hold_id, { + basis: this.assessment.basis, + restorableAmount: this.possible ? this.assessment.restorableAmount : null, + remarks: this.assessment.remarks + }), + 'Assessment saved. Enter the same on MRM (Money Distribution Process).' + ); + } + + saveRequest(): void { + this.run(this.i4c.recordRestorationRequest(this.selected.id, this.request), 'Victim request recorded.'); + } + + takeAction(): void { + this.run( + this.i4c.restorationStep(this.selected.id, 'bank-action', this.action), + this.action.action === 'COMPLIED' + ? 'Restoration raised. A second officer approves it on the Approvals page; the money then moves.' + : 'Bank action recorded. Enter the same on MRM.' + ); + } + + retryOutward(): void { + this.run(this.i4c.restorationStep(this.selected.id, 'retry-outward'), 'Outward transfer sent again.'); + } + + markMrm(): void { + this.run(this.i4c.restorationStep(this.selected.id, 'mrm-updated'), 'Marked as entered on MRM.'); + } + + saveReceipt(): void { + this.run(this.i4c.restorationStep(this.selected.id, 'receipt', this.receipt), 'Receipt recorded.'); + } + + saveVictimStatus(): void { + this.run( + this.i4c.restorationStep(this.selected.id, 'victim-status', this.victim), + 'Victim status recorded; case closed.' + ); + } + + loadProRata(): void { + this.i4c.proRata(this.selected.hold_account_no).subscribe({ + next: (p) => { + this.proRata = p; + const mine = (p.holds || []).find((h: any) => h.id === this.selected.hold_id); + if (mine) { + this.assessment.basis = 'PRO_RATA'; + this.assessment.restorableAmount = Number(mine.share); + } + }, + error: (e) => this.snackBar.open(i4cErrorMessage(e), 'Close', { duration: 6000 }) + }); + } +} diff --git a/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.html b/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.html new file mode 100644 index 000000000..e633cc21c --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.html @@ -0,0 +1,215 @@ + + +
+ +
+
+
+ +
+
+
+ I4C Portal + + + Live MHA Gateway + +
+
National Cyber Crime Reporting & Account Holds System
+
+
+ + +
+ +
+
+ + + + + +
+ +
+ + + @if (showLookupModal) { +
+
+
+
+ +
+ Quick Search & Lookup +
Instant search across I4C Complaints and Account Holds Register
+
+
+ +
+ +
+ + + @if (lookupError) { +
{{ lookupError }}
+ } + + + @if (lookupSearched) { +
+ +
+
+ + Complaints from I4C + {{ lookupResults.complaints.length }} +
+ @if (lookupResults.complaints.length > 0) { +
+ @for (c of lookupResults.complaints; track c.id) { +
+
+
+ {{ c.acknowledgement_no }} + {{ c.status }} +
+
+ Account: {{ c.payer_account_number }} + • + Job: {{ c.job_id }} + • + Sub-cat: {{ c.sub_category }} +
+
+
+ Held: ₹{{ c.amount_held | number: '1.2-2' }} + Open Complaint +
+
+ } +
+ } @else { +
No complaints matched "{{ lookupQuery }}".
+ } +
+ + +
+
+ + Holds Register + {{ lookupResults.holds.length }} +
+ @if (lookupResults.holds.length > 0) { +
+ @for (h of lookupResults.holds; track h.id) { +
+
+
+ Account: {{ h.account_no }} + {{ h.status }} + @if (h.debit_frozen) { + DEBIT FROZEN + } +
+
+ Customer: {{ h.client_name || 'N/A' }} + • + Ack: {{ h.acknowledgement_no }} + • + Layer: {{ h.layer }} +
+
+
+ Held: ₹{{ h.amount_held | number: '1.2-2' }} + View in Holds +
+
+ } +
+ } @else { +
No holds matched "{{ lookupQuery }}".
+ } +
+
+ } @else { +
+ + Type a 14-digit acknowledgement number, customer bank account number, or job ID to quickly locate + complaints or hold records. +
+ } +
+ + +
+
+ } +
diff --git a/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.scss b/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.scss new file mode 100644 index 000000000..3878f045f --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.scss @@ -0,0 +1,473 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +@use '../i4c' as *; + +@include i4c-skin; + +.i4c-shell { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Modern Top Header / Navbar */ +.i4c-navbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); + border-radius: 14px; + box-shadow: 0 10px 25px -5px rgb(15 23 42 / 20%); + color: #fff; + + &__brand { + display: flex; + align-items: center; + gap: 10px; + } + + &__icon-wrap { + width: 34px; + height: 34px; + border-radius: 9px; + background: linear-gradient(135deg, #2563eb, #3b82f6); + display: flex; + align-items: center; + justify-content: center; + font-size: 15px; + color: #fff; + box-shadow: 0 4px 10px rgb(37 99 235 / 35%); + } + + &__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 800; + letter-spacing: -0.02em; + } + + &__sub { + font-size: 10.5px; + color: #94a3b8; + margin-top: 1px; + } + + .live-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 7px; + border-radius: 9999px; + font-size: 10px; + font-weight: 700; + background: rgb(16 185 129 / 15%); + color: #34d399; + border: 1px solid rgb(16 185 129 / 30%); + + &__dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: #10b981; + box-shadow: 0 0 6px #10b981; + animation: pulse-dot 1.8s infinite; + } + } + + .quick-lookup-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: 9px; + background: rgb(255 255 255 / 10%); + color: #fff; + border: 1px solid rgb(255 255 255 / 18%); + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + + &:hover { + background: rgb(255 255 255 / 20%); + transform: translateY(-1px); + box-shadow: 0 4px 10px rgb(0 0 0 / 15%); + } + + .kbd-shortcut { + padding: 1px 5px; + border-radius: 4px; + background: rgb(255 255 255 / 15%); + font-size: 9px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #93c5fd; + } + } +} + +/* Modern Pill Navigation Bar */ +.i4c-subnav { + display: flex; + align-items: center; + padding: 4px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 2px 8px rgb(15 23 42 / 3%); + overflow-x: auto; + + &__links { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 4px; + width: 100%; + } + + &__link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 8px; + font-size: 11.5px; + font-weight: 600; + color: #475569; + text-decoration: none; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + white-space: nowrap; + + .link-ic { + font-size: 12px; + color: #64748b; + transition: color 0.2s; + } + + &:hover { + color: #0f172a; + background: #f1f5f9; + + .link-ic { + color: #2563eb; + } + } + + &--active { + background: #2563eb; + color: #fff !important; + box-shadow: 0 3px 10px rgb(37 99 235 / 28%); + + .link-ic { + color: #fff; + } + + .pill-badge { + background: rgb(255 255 255 / 25%); + color: #fff; + } + } + } + + .pill-badge { + padding: 1px 6px; + border-radius: 9999px; + font-size: 9.5px; + font-weight: 800; + + &--amber { + background: #fef3c7; + color: #b45309; + } + + &--blue { + background: #eff6ff; + color: #1d4ed8; + } + } +} + +/* Quick Lookup Popup Modal styling */ +.lookup-search-box { + display: flex; + gap: 8px; + align-items: center; + + .search-input-wrap { + flex: 1 1 auto; + position: relative; + display: flex; + align-items: center; + + .search-icon { + position: absolute; + left: 11px; + color: #64748b; + font-size: 13px; + } + + .modern-input { + width: 100%; + height: 38px; + padding: 0 32px 0 34px; + border-radius: 9px; + border: 1px solid #cbd5e1; + background: #f8fafc; + font-size: 12px; + color: #0f172a; + outline: none; + transition: all 0.2s; + + &:focus { + background: #fff; + border-color: #2563eb; + box-shadow: 0 0 0 3px rgb(37 99 235 / 15%); + } + } + + .clear-btn { + position: absolute; + right: 10px; + background: none; + border: none; + color: #94a3b8; + cursor: pointer; + + &:hover { + color: #0f172a; + } + } + } + + .search-submit-btn { + height: 38px; + padding: 0 14px; + border-radius: 9px; + background: #2563eb; + color: #fff; + border: none; + font-size: 12px; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + transition: all 0.2s; + + &:hover:not(:disabled) { + background: #1d4ed8; + box-shadow: 0 3px 10px rgb(37 99 235 / 30%); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } +} + +.lookup-hint { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-radius: 10px; + background: #f8fafc; + border: 1px dashed #cbd5e1; + color: #64748b; + font-size: 11.5px; + + .hint-icon { + font-size: 16px; + color: #3b82f6; + } +} + +.lookup-results { + display: flex; + flex-direction: column; + gap: 20px; +} + +.results-section { + &__head { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 700; + color: #1e293b; + margin-bottom: 10px; + + fa-icon { + color: #2563eb; + } + + .count-badge { + display: inline-block; + padding: 1px 7px; + border-radius: 9999px; + background: #e2e8f0; + font-size: 11px; + font-weight: 700; + color: #475569; + } + } + + .results-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .result-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #eff6ff; + border-color: #93c5fd; + transform: translateY(-1px); + } + + &__main { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__title { + display: flex; + align-items: center; + gap: 10px; + + .ack-code { + font-weight: 700; + color: #0f172a; + font-size: 14px; + } + } + + &__sub { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #64748b; + } + + &__side { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + + .held-amt { + font-weight: 700; + color: #0f172a; + font-size: 13px; + } + + .open-link { + font-size: 11px; + font-weight: 600; + color: #2563eb; + display: inline-flex; + align-items: center; + gap: 4px; + } + } + } + + .no-res { + padding: 16px; + background: #f8fafc; + border-radius: 10px; + color: #94a3b8; + font-size: 13px; + text-align: center; + } +} + +.status-pill { + padding: 2px 8px; + border-radius: 9999px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + background: #e2e8f0; + color: #475569; + + &--processed, + &--active { + background: #dcfce7; + color: #166534; + } + + &--retry, + &--failed { + background: #fee2e2; + color: #991b1b; + } + + &--processing, + &--received { + background: #fef3c7; + color: #92400e; + } +} + +.freeze-tag { + padding: 2px 8px; + border-radius: 9999px; + font-size: 10px; + font-weight: 700; + background: #fee2e2; + color: #991b1b; +} + +.footer-btn-close { + padding: 8px 18px; + border-radius: 10px; + background: #e2e8f0; + color: #334155; + border: none; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #cbd5e1; + color: #0f172a; + } +} + +@keyframes pulse-dot { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + + 50% { + opacity: 0.4; + transform: scale(0.8); + } +} diff --git a/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.ts b/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.ts new file mode 100644 index 000000000..3920727dd --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c-shell/i4c-shell.component.ts @@ -0,0 +1,141 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Component, OnInit, inject } from '@angular/core'; +import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { I4cService, i4cErrorMessage } from '../i4c.service'; + +/** + * The live I4C (NCRP-CFCFRMS) integration: complaints I4C pushed to the bank, the holds placed for them, the + * approvals to release them, and every response sent back. Backed by /v1/i4c/admin on the CBS, never by the + * CFCFRMS demo mock. + */ +@Component({ + selector: 'mifosx-i4c-shell', + templateUrl: './i4c-shell.component.html', + styleUrls: ['./i4c-shell.component.scss'], + imports: [ + FormsModule, + RouterLink, + RouterLinkActive, + RouterOutlet, + MatButtonModule, + FaIconComponent + ] +}) +export class I4cShellComponent implements OnInit { + private i4c = inject(I4cService); + private router = inject(Router); + + readonly links = [ + { path: 'dashboard', label: 'Dashboard', icon: 'chart-line' }, + { path: 'requests', label: 'Complaints from I4C', icon: 'inbox' }, + { path: 'holds', label: 'Holds Register', icon: 'lock' }, + { path: 'approvals', label: 'Approvals', icon: 'user-check' }, + { path: 'grievances', label: 'GRM Grievances', icon: 'balance-scale' }, + { path: 'restorations', label: 'MRM Restorations', icon: 'hand-holding-usd' }, + { path: 'mrm-report', label: 'MRM Balance Report', icon: 'file-csv' }, + { path: 'outbox', label: 'Responses & API Log', icon: 'paper-plane' }, + { path: 'bank-master', label: 'Bank Code Master', icon: 'university' }, + { path: 'intimation-settings', label: 'Intimation Settings', icon: 'cog' } + ]; + + // Quick lookup popup modal state + showLookupModal = false; + lookupQuery = ''; + lookupLoading = false; + lookupResults: { complaints: any[]; holds: any[] } = { complaints: [], holds: [] }; + lookupSearched = false; + lookupError = ''; + + // Summary counts for pill badges + pendingApprovals = 0; + activeHolds = 0; + + ngOnInit(): void { + this.refreshSummary(); + } + + refreshSummary(): void { + this.i4c.dashboard().subscribe({ + next: (d) => { + this.pendingApprovals = d?.pendingApprovals || 0; + this.activeHolds = d?.activeHolds || 0; + }, + error: () => {} + }); + } + + openLookup(): void { + this.showLookupModal = true; + this.lookupQuery = ''; + this.lookupResults = { complaints: [], holds: [] }; + this.lookupSearched = false; + this.lookupError = ''; + } + + closeLookup(): void { + this.showLookupModal = false; + } + + performLookup(): void { + const q = this.lookupQuery.trim(); + if (!q) { + return; + } + this.lookupLoading = true; + this.lookupSearched = true; + this.lookupError = ''; + this.lookupResults = { complaints: [], holds: [] }; + + let completed = 0; + const checkDone = () => { + completed++; + if (completed >= 2) { + this.lookupLoading = false; + } + }; + + this.i4c.requests({ search: q, limit: 10 }).subscribe({ + next: (rows) => { + this.lookupResults.complaints = rows?.pageItems || []; + checkDone(); + }, + error: (e) => { + this.lookupError = i4cErrorMessage(e); + checkDone(); + } + }); + + this.i4c.holds({ search: q, limit: 10 }).subscribe({ + next: (rows) => { + this.lookupResults.holds = rows?.pageItems || []; + checkDone(); + }, + error: (e) => { + checkDone(); + } + }); + } + + goToComplaint(id: number): void { + this.closeLookup(); + this.router.navigate([ + '/cfcfrms/i4c/requests', + id + ]); + } + + goToHold(accountNo: string): void { + this.closeLookup(); + this.router.navigate(['/cfcfrms/i4c/holds'], { queryParams: { view: '' } }); + } +} diff --git a/src/app/cfcfrms/i4c/i4c.service.ts b/src/app/cfcfrms/i4c/i4c.service.ts new file mode 100644 index 000000000..400d6f518 --- /dev/null +++ b/src/app/cfcfrms/i4c/i4c.service.ts @@ -0,0 +1,384 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** A hold the bank placed for an I4C complaint (m_i4c_hold). */ +export interface I4cHold { + id: number; + request_id: number; + incident_id: number; + acknowledgement_no: string; + account_type: 'SAVINGS' | 'CURRENT'; + account_id: number; + account_no: string; + client_id?: number; + client_name?: string; + office_name?: string; + layer: number; + disputed_amount: number; + amount_held: number; + shortfall: number; + status: 'ACTIVE' | 'RELEASED' | 'NOT_HOLDABLE'; + debit_frozen: boolean; + held_at: string; + review_due_on: string; + intimation_due_by?: string; + customer_intimated_at?: string; + intimation_mode?: string; + intimation_reference?: string; + intimated_by?: string; + released_at?: string; + release_reason_type?: string; + release_reference?: string; + pending_actions?: number; + transactions?: any[]; + actions?: any[]; + /** Every notice sent or recorded for this hold, newest first. */ + intimationLog?: IntimationLogEntry[]; + /** Channels switched on for automatic intimation, in the order they are tried. */ + automaticChannels?: string[]; + grievances?: any[]; + restorations?: any[]; + documents?: I4cDocument[]; + released_amount?: number; + restored_amount?: number; + auto_intimation_attempts?: number; +} + +export interface IntimationLogEntry { + id: number; + channel: string; + mode: 'AUTO' | 'MANUAL' | 'RESEND'; + status: string; + destination_masked?: string; + provider?: string; + provider_reference?: string; + failure_reason?: string; + actor?: string; + created_at: string; +} + +/** An automatic customer-intimation channel (m_i4c_intimation_channel). */ +export interface IntimationChannel { + channel: 'EMAIL' | 'SMS' | 'WHATSAPP'; + enabled: boolean; + priority: number; + subject?: string; + body: string; + updated_by?: string; + updated_at?: string; +} + +export interface IntimationSettings { + channels: IntimationChannel[]; + placeholders: string[]; + manualChannels: string[]; +} + +export interface IntimationResult { + outcome: 'SENT' | 'NO_CHANNEL_ENABLED' | 'NOT_DELIVERED' | 'ALREADY_INTIMATED' | 'NOTHING_HELD'; + channel?: string; + destination?: string; + message: string; +} + +export type I4cDocumentOwner = 'HOLD' | 'GRIEVANCE' | 'RESTORATION'; + +/** A document attached to a hold, grievance or restoration (content downloaded separately). */ +export interface I4cDocument { + id: number; + doc_type: string; + file_name: string; + content_type: string; + size_bytes: number; + sha256: string; + remarks?: string; + uploaded_by: string; + uploaded_at: string; +} + +export const DOCUMENT_TYPES: { value: string; label: string }[] = [ + { value: 'IO_ORDER', label: 'IO order' }, + { value: 'GRM_ORDER', label: 'GRM order (DGRO / SGRO)' }, + { value: 'COURT_ORDER', label: 'Court order' }, + { value: 'BNSS_106_NOTICE', label: 'Notice u/s 106 BNSS' }, + { value: 'INDEMNITY_BOND', label: 'Indemnity bond' }, + { value: 'FIR', label: 'FIR copy' }, + { value: 'KYC', label: 'KYC document' }, + { value: 'APPLICATION', label: "Customer's application" }, + { value: 'PHOTO', label: 'Photograph' }, + { value: 'BANK_STATEMENT', label: 'Bank statement' }, + { value: 'EDD_REPORT', label: 'EDD report' }, + { value: 'PAN', label: 'PAN card' }, + { value: 'OTHER', label: 'Other' } +]; + +/** Save a downloaded file (CSV, document) under the given name. */ +export function saveBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +/** One page of an officer list, as every /i4c/admin list endpoint returns it. */ +export interface I4cPage { + totalFilteredRecords: number; + pageItems: T[]; +} + +/** Query-string filters of a list; empty values are left out of the request. */ +export type I4cListParams = Record; + +export interface HoldActionRequest { + action: 'RELEASE' | 'FREEZE' | 'UNFREEZE' | 'UNFREEZE_AND_RELEASE'; + /** RELEASE only: how much to release; empty releases all. */ + amount?: number | null; + reasonType: string; + orderReference?: string; + orderDate?: string; + authority?: string; + remarks?: string; +} + +export const HOLD_REASON_TYPES: { value: string; label: string }[] = [ + { value: 'IO_ORDER', label: 'Investigating Officer order' }, + { value: 'GRM_DECISION', label: 'GRM decision (IO / DGRO / SGRO)' }, + { value: 'MRM_RESTORATION', label: 'MRM money restoration' }, + { value: 'COURT_ORDER', label: 'Court order' }, + { value: 'LEA_DIRECTION', label: 'LEA direction' }, + { value: 'REVIEW_90_DAYS', label: '90-day review (no direction received)' }, + { value: 'WRONG_ACCOUNT', label: 'Wrong account reported' }, + { value: 'OTHER', label: 'Other' } +]; + +/** Officer API of the I4C (NCRP-CFCFRMS) integration: /v1/i4c/admin/* on the CBS backend. */ +@Injectable({ providedIn: 'root' }) +export class I4cService { + private http = inject(HttpClient); + private base = '/i4c/admin'; + private cases = '/i4c/cases'; + + dashboard(): Observable { + return this.http.get(`${this.base}/dashboard`); + } + + /** Filters: status, search, subCategory, layer, response, fromDate, toDate, offset, limit. */ + requests(query: I4cListParams = {}): Observable> { + return this.http.get>(`${this.base}/requests`, { params: this.params(query) }); + } + + request(id: number): Observable { + return this.http.get(`${this.base}/requests/${id}`); + } + + reprocess(id: number): Observable { + return this.http.post(`${this.base}/requests/${id}/reprocess`, {}); + } + + /** Filters: view, search, accountType, intimation, frozen, layer, fromDate, toDate, offset, limit. */ + holds(query: I4cListParams = {}): Observable> { + return this.http.get>(`${this.base}/holds`, { params: this.params(query) }); + } + + hold(id: number): Observable { + return this.http.get(`${this.base}/holds/${id}`); + } + + requestHoldAction(holdId: number, body: HoldActionRequest): Observable { + return this.http.post(`${this.base}/holds/${holdId}/actions`, body); + } + + recordIntimation(holdId: number, mode: string, reference?: string): Observable { + return this.http.post(`${this.base}/holds/${holdId}/intimation`, { mode, reference }); + } + + /** Filters: status, action, reasonType, search, fromDate, toDate, offset, limit. */ + holdActions(query: I4cListParams = {}): Observable> { + return this.http.get>(`${this.base}/hold-actions`, { params: this.params(query) }); + } + + approveHoldAction(id: number): Observable { + return this.http.post(`${this.base}/hold-actions/${id}/approve`, {}); + } + + rejectHoldAction(id: number, reason: string): Observable { + return this.http.post(`${this.base}/hold-actions/${id}/reject`, { reason }); + } + + /** Filters: status, type (ACK / RESPONSE), search, fromDate, toDate, offset, limit. */ + outbox(query: I4cListParams = {}): Observable> { + return this.http.get>(`${this.base}/outbox`, { params: this.params(query) }); + } + + retryOutbox(id: number): Observable { + return this.http.post(`${this.base}/outbox/${id}/retry`, {}); + } + + /** Filters: direction, failures, httpStatus, responseCode, search, fromDate, toDate, offset, limit. */ + apiLog(query: I4cListParams = {}): Observable> { + return this.http.get>(`${this.base}/api-log`, { params: this.params(query) }); + } + + apiLogEntry(id: number): Observable { + return this.http.get(`${this.base}/api-log/${id}`); + } + + /** Send the hold notice now on the switched-on automatic channels. */ + sendIntimation(holdId: number): Observable { + return this.http.post(`${this.base}/holds/${holdId}/intimation/send`, {}); + } + + intimationChannels(): Observable { + return this.http.get(`${this.base}/intimation-channels`); + } + + updateIntimationChannel( + channel: string, + body: { enabled?: boolean; priority?: number; subject?: string; body?: string } + ): Observable { + return this.http.put(`${this.base}/intimation-channels/${channel}`, body); + } + + testIntimationChannel(channel: string, to: string): Observable { + return this.http.post(`${this.base}/intimation-channels/${channel}/test`, { to }); + } + + /** Filters: search, type, ifsc (KNOWN / UNKNOWN), offset, limit. */ + bankMaster(query: I4cListParams = {}): Observable> { + return this.http.get>(`${this.base}/bank-master`, { params: this.params(query) }); + } + + // ── GRM / MRM (/v1/i4c/cases) ────────────────────────────────────────── + + grievances(query: I4cListParams = {}): Observable> { + return this.http.get>(`${this.cases}/grievances`, { params: this.params(query) }); + } + + grievance(id: number): Observable { + return this.http.get(`${this.cases}/grievances/${id}`); + } + + createGrievance( + holdId: number, + body: { customerRequest: string; customerReason?: string } + ): Observable<{ resourceId: number }> { + return this.http.post<{ resourceId: number }>(`${this.cases}/holds/${holdId}/grievances`, body); + } + + /** step: edd, submit, nodal, events, investigation, decision, escalate, withdraw */ + grievanceStep(id: number, step: string, body: Record): Observable { + return this.http.post(`${this.cases}/grievances/${id}/${step}`, body); + } + + raiseGrievanceAction(id: number): Observable<{ resourceId: number; actionRequestId: number | null }> { + return this.http.post<{ resourceId: number; actionRequestId: number | null }>( + `${this.cases}/grievances/${id}/action`, + {} + ); + } + + restorations(query: I4cListParams = {}): Observable> { + return this.http.get>(`${this.cases}/restorations`, { params: this.params(query) }); + } + + restoration(id: number): Observable { + return this.http.get(`${this.cases}/restorations/${id}`); + } + + assessRestoration( + holdId: number, + body: { basis: string; restorableAmount?: number | null; remarks?: string } + ): Observable<{ resourceId: number }> { + return this.http.post<{ resourceId: number }>(`${this.cases}/holds/${holdId}/restorations`, body); + } + + recordRestorationRequest(id: number, body: Record): Observable { + return this.http.put(`${this.cases}/restorations/${id}/request`, body); + } + + /** step: bank-action, retry-outward, mrm-updated, receipt, victim-status */ + restorationStep(id: number, step: string, body: Record = {}): Observable { + return this.http.post(`${this.cases}/restorations/${id}/${step}`, body); + } + + openVictimCase(acknowledgementNo: string): Observable<{ resourceId: number }> { + return this.http.post<{ resourceId: number }>(`${this.cases}/victim-cases`, { acknowledgementNo }); + } + + proRata(accountNo: string, amount?: number | null): Observable { + return this.http.get(`${this.cases}/pro-rata`, { params: this.params({ accountNo, amount }) }); + } + + mrmBalanceReport(): Observable { + return this.http.get(`${this.cases}/mrm-balance-report`); + } + + mrmBalanceReportCsv(): Observable { + return this.http.get(`${this.cases}/mrm-balance-report.csv`, { responseType: 'blob' }); + } + + statementCsv(holdId: number, fromDate?: string, toDate?: string): Observable { + return this.http.get(`${this.cases}/holds/${holdId}/statement.csv`, { + params: this.params({ fromDate, toDate }), + responseType: 'blob' + }); + } + + documents(ownerType: I4cDocumentOwner, ownerId: number): Observable { + return this.http.get(`${this.cases}/documents`, { params: this.params({ ownerType, ownerId }) }); + } + + uploadDocument(body: { + ownerType: I4cDocumentOwner; + ownerId: number; + docType: string; + fileName: string; + contentType: string; + content: string; + remarks?: string; + }): Observable<{ resourceId: number }> { + return this.http.post<{ resourceId: number }>(`${this.cases}/documents`, body); + } + + downloadDocument(id: number): Observable { + return this.http.get(`${this.cases}/documents/${id}/content`, { responseType: 'blob' }); + } + + private params(values: Record): HttpParams { + let params = new HttpParams(); + Object.entries(values).forEach( + ([ + key, + value + ]) => { + if (value !== undefined && value !== null && value !== '') { + params = params.set(key, String(value)); + } + } + ); + return params; + } +} + +/** Backend errors come as Fineract's { defaultUserMessage, errors[] }; this picks the sentence a user should read. */ +export function i4cErrorMessage(error: any): string { + const body = error?.error; + return ( + body?.errors?.[0]?.defaultUserMessage || + body?.defaultUserMessage || + body?.developerMessage || + error?.message || + 'Request failed' + ); +}