feat: add CFCFRMS module, bank menus, mock backend, and dashboard redesign

- CFCFRMS cyber fraud complaint management with dashboard, complaints,
  escalation, report suspect/mule/fraud txn, FMR submission, CFR lookup
- Mock HTTP interceptor with 20 dummy complaints and all endpoints
- Simulator service for I4C/MHA integration testing
- 3-row menu bar with 22 items (7+8+8 layout)
- Mega menus for General Ledger, Sub Ledger, Deposits, Share, Others,
  HRM, RBI with search and grouped columns
- Dropdowns for Cashier, Clearing, NPA, CKYC, Advances, Letter Mgmt,
  Insurance, Digital
- Dashboard redesign with regulatory, financial health, deposits,
  risk/NPA sections using static banking data
- All translations added to en-US.json
- FontAwesome icons registered for all new menu items

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aditya
2026-03-18 18:05:43 +05:30
co-authored by Claude Opus 4.6
parent 0a32d7135e
commit b3a4cce0cf
55 changed files with 9667 additions and 298 deletions
@@ -0,0 +1,220 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="escalate-page">
<div class="page-header">
<h2>{{ 'labels.menus.Escalate Complaint' | translate }}</h2>
<p class="subtitle">{{ 'labels.text.Forward complaint to the next bank in the transaction chain' | translate }}</p>
</div>
<div class="escalate-layout">
<!-- Escalation Form -->
<mat-card class="escalate-card">
<mat-card-header>
<mat-card-title>
<fa-icon icon="arrow-up" class="mr-05"></fa-icon> {{ 'labels.text.Escalation Form' | translate }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
@if (escalationResult) {
<div class="success-result">
<div class="success-icon">
<fa-icon icon="check-circle" size="3x"></fa-icon>
</div>
<h3>{{ 'labels.text.Escalation Successful' | translate }}</h3>
<div class="result-details">
<div class="result-row">
<span class="result-label">{{ 'labels.text.Reference Number' | translate }}</span>
<span class="result-value mono">{{ escalationResult.referenceNumber }}</span>
</div>
<div class="result-row">
<span class="result-label">{{ 'labels.text.Ticket ID' | translate }}</span>
<span class="result-value">{{ escalationResult.ticketId }}</span>
</div>
<div class="result-row">
<span class="result-label">{{ 'labels.text.Escalated To' | translate }}</span>
<span class="result-value">{{ escalationResult.escalatedTo }}</span>
</div>
<div class="result-row">
<span class="result-label">{{ 'labels.text.Bank IFSC' | translate }}</span>
<span class="result-value mono">{{ escalationResult.escalatedBankIfsc }}</span>
</div>
<div class="result-row">
<span class="result-label">{{ 'labels.text.Status' | translate }}</span>
<span class="result-value">{{ escalationResult.status }}</span>
</div>
<div class="result-row">
<span class="result-label">{{ 'labels.text.Timestamp' | translate }}</span>
<span class="result-value">{{ escalationResult.timestamp }}</span>
</div>
</div>
<button mat-raised-button color="primary" (click)="resetForm()" class="mt-16">
<fa-icon icon="plus" class="mr-05"></fa-icon> {{ 'labels.text.New Escalation' | translate }}
</button>
</div>
} @else {
<form [formGroup]="escalateForm" (ngSubmit)="onSubmit()">
<div class="ticket-lookup-row">
<mat-form-field appearance="outline" class="ticket-field">
<mat-label>{{ 'labels.text.Ticket ID' | translate }}</mat-label>
<input matInput formControlName="ticketId" placeholder="e.g. CFCFRMS-2024-00001" />
@if (escalateForm.get('ticketId')?.hasError('required') && escalateForm.get('ticketId')?.touched) {
<mat-error>{{ 'labels.text.Ticket ID' | translate }} is required</mat-error>
}
</mat-form-field>
<button
mat-raised-button
type="button"
(click)="lookupTicket()"
[disabled]="isLookingUp"
class="lookup-btn"
>
@if (isLookingUp) {
<mat-spinner diameter="20"></mat-spinner>
} @else {
<fa-icon icon="search" class="mr-05"></fa-icon> {{ 'labels.text.Lookup' | translate }}
}
</button>
</div>
@if (lookedUpComplaint) {
<div class="lookup-result">
<div class="lookup-row">
<span>{{ 'labels.text.Complainant' | translate }}:</span>
<strong>{{ lookedUpComplaint.complainantName }}</strong>
</div>
<div class="lookup-row">
<span>{{ 'labels.text.Fraud Amount' | translate }}:</span>
<strong>{{ formatCurrency(lookedUpComplaint.fraudAmount) }}</strong>
</div>
<div class="lookup-row">
<span>{{ 'labels.text.Suspect Account' | translate }}:</span>
<strong class="mono">{{ lookedUpComplaint.suspectAccountNumber }}</strong>
</div>
<div class="lookup-row">
<span>{{ 'labels.text.Current Status' | translate }}:</span>
<cfcfrms-status-badge [status]="lookedUpComplaint.status"></cfcfrms-status-badge>
</div>
</div>
}
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'labels.text.Next Hop Account Number' | translate }}</mat-label>
<input
matInput
formControlName="nextHopAccountNumber"
placeholder="{{ 'labels.text.Account Number' | translate }}"
/>
@if (
escalateForm.get('nextHopAccountNumber')?.hasError('required') &&
escalateForm.get('nextHopAccountNumber')?.touched
) {
<mat-error>{{ 'labels.text.Account number is required' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'labels.text.Next Hop Bank IFSC' | translate }}</mat-label>
<input matInput formControlName="nextHopBankIfsc" placeholder="e.g. SBIN0000001" />
@if (
escalateForm.get('nextHopBankIfsc')?.hasError('required') &&
escalateForm.get('nextHopBankIfsc')?.touched
) {
<mat-error>{{ 'labels.text.IFSC code is required' | translate }}</mat-error>
}
@if (
escalateForm.get('nextHopBankIfsc')?.hasError('pattern') && escalateForm.get('nextHopBankIfsc')?.touched
) {
<mat-error>{{ 'labels.text.Invalid IFSC format' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'labels.text.Remarks' | translate }}</mat-label>
<textarea
matInput
formControlName="remarks"
rows="4"
placeholder="{{ 'labels.text.Reason for escalation...' | translate }}"
></textarea>
@if (escalateForm.get('remarks')?.hasError('required') && escalateForm.get('remarks')?.touched) {
<mat-error>{{ 'labels.text.Remarks are required' | translate }}</mat-error>
}
</mat-form-field>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="isSubmitting"
class="full-width submit-btn"
>
@if (isSubmitting) {
<mat-spinner diameter="20" class="inline-spinner"></mat-spinner>
} @else {
<fa-icon icon="paper-plane" class="mr-05"></fa-icon> {{ 'labels.text.Submit Escalation' | translate }}
}
</button>
</form>
}
</mat-card-content>
</mat-card>
<!-- Instructions Panel -->
<mat-card class="instructions-card">
<mat-card-header>
<mat-card-title>
<fa-icon icon="info-circle" class="mr-05"></fa-icon> {{ 'labels.text.Escalation Guidelines' | translate }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="guidelines">
<div class="guideline-item">
<fa-icon icon="check" class="guideline-icon"></fa-icon>
<p>{{ 'labels.text.Verify the complaint details before escalating to the next bank.' | translate }}</p>
</div>
<div class="guideline-item">
<fa-icon icon="check" class="guideline-icon"></fa-icon>
<p>
{{
'labels.text.Ensure the Next Hop Account and IFSC belong to the next bank in the fraud transaction chain.'
| translate
}}
</p>
</div>
<div class="guideline-item">
<fa-icon icon="check" class="guideline-icon"></fa-icon>
<p>{{ 'labels.text.Provide clear and detailed remarks explaining the escalation reason.' | translate }}</p>
</div>
<div class="guideline-item">
<fa-icon icon="exclamation-triangle" class="guideline-icon warn"></fa-icon>
<p>{{ 'labels.text.Escalation must be completed within the I4C/MHA mandated timeline.' | translate }}</p>
</div>
<div class="guideline-item">
<fa-icon icon="exclamation-triangle" class="guideline-icon warn"></fa-icon>
<p>
{{
'labels.text.Once escalated, the complaint will be forwarded to the target bank for action.' | translate
}}
</p>
</div>
</div>
<div class="bank-info">
<h4>{{ 'labels.text.Current Bank Info' | translate }}</h4>
<div class="bank-row">
<span>{{ 'labels.text.Bank Name' | translate }}:</span> <strong>LincPay</strong>
</div>
<div class="bank-row">
<span>{{ 'labels.text.Bank IFSC' | translate }}:</span> <strong class="mono">LINC0000001</strong>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
@@ -0,0 +1,211 @@
/**
* 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/.
*/
.escalate-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #0d1b2a;
}
.subtitle {
margin: 4px 0 0;
color: #64748b;
font-size: 14px;
}
}
.escalate-layout {
display: grid;
grid-template-columns: 1fr 360px;
gap: 24px;
align-items: start;
@media (max-width: 1280px) {
grid-template-columns: 1fr;
}
}
.escalate-card,
.instructions-card {
border-radius: 8px !important;
mat-card-header {
padding: 16px 20px 0;
}
mat-card-content {
padding: 16px 20px 20px !important;
}
}
.ticket-lookup-row {
display: flex;
gap: 12px;
align-items: flex-start;
.ticket-field {
flex: 1;
}
.lookup-btn {
margin-top: 4px;
height: 56px;
}
}
.lookup-result {
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
.lookup-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 14px;
span {
color: #64748b;
}
}
}
.full-width {
width: 100%;
}
.submit-btn {
margin-top: 8px;
height: 44px;
}
.inline-spinner {
display: inline-block;
}
.mr-05 {
margin-right: 4px;
}
.mt-16 {
margin-top: 16px;
}
.mono {
font-family: 'Courier New', monospace;
}
// Success result
.success-result {
text-align: center;
padding: 24px 0;
.success-icon {
color: #10b981;
margin-bottom: 12px;
}
h3 {
margin: 0 0 20px;
color: #065f46;
font-size: 20px;
}
}
.result-details {
text-align: left;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 8px;
padding: 16px;
.result-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 14px;
.result-label {
color: #64748b;
}
.result-value {
font-weight: 600;
color: #1e293b;
}
}
}
// Instructions card
.guidelines {
margin-bottom: 20px;
}
.guideline-item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 8px 0;
.guideline-icon {
color: #10b981;
margin-top: 2px;
flex-shrink: 0;
&.warn {
color: #f59e0b;
}
}
p {
margin: 0;
font-size: 13px;
color: #475569;
line-height: 1.5;
}
}
.bank-info {
background: #f8fafc;
border-radius: 8px;
padding: 16px;
border: 1px solid #e2e8f0;
h4 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 700;
color: #0d1b2a;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.bank-row {
display: flex;
justify-content: space-between;
padding: 4px 0;
font-size: 14px;
span {
color: #64748b;
}
}
}
@@ -0,0 +1,169 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { TranslateModule } from '@ngx-translate/core';
import { CfcfrmsService } from '../cfcfrms.service';
import { StatusBadgeComponent } from '../components/status-badge/status-badge.component';
import { ConfirmDialogComponent } from '../components/confirm-dialog/confirm-dialog.component';
import { Complaint, EscalationResponse } from '../models/cfcfrms.models';
@Component({
selector: 'cfcfrms-escalate',
standalone: true,
imports: [
CommonModule,
RouterModule,
ReactiveFormsModule,
MatCardModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatProgressSpinnerModule,
MatDialogModule,
MatSnackBarModule,
FaIconComponent,
TranslateModule,
StatusBadgeComponent
],
templateUrl: './escalate.component.html',
styleUrls: ['./escalate.component.scss']
})
export class EscalateComponent {
private fb = inject(FormBuilder);
private cfcfrmsService = inject(CfcfrmsService);
private dialog = inject(MatDialog);
private snackBar = inject(MatSnackBar);
escalateForm: FormGroup;
isSubmitting = false;
isLookingUp = false;
lookedUpComplaint: Complaint | null = null;
escalationResult: EscalationResponse | null = null;
constructor() {
this.escalateForm = this.fb.group({
ticketId: [
'',
Validators.required
],
nextHopAccountNumber: [
'',
Validators.required
],
nextHopBankIfsc: [
'',
[
Validators.required,
Validators.pattern(/^[A-Z]{4}0[A-Z0-9]{6}$/)
]
],
remarks: [
'',
Validators.required
]
});
}
lookupTicket(): void {
const ticketId = this.escalateForm.get('ticketId')?.value?.trim();
if (!ticketId) {
this.snackBar.open('Enter a Ticket ID first', 'Close', { duration: 3000 });
return;
}
this.isLookingUp = true;
this.lookedUpComplaint = null;
this.cfcfrmsService.getComplaintById(ticketId).subscribe({
next: (complaint) => {
this.lookedUpComplaint = complaint;
this.isLookingUp = false;
},
error: () => {
this.isLookingUp = false;
this.snackBar.open('Ticket not found', 'Close', { duration: 3000 });
}
});
}
onSubmit(): void {
if (this.escalateForm.invalid) {
this.escalateForm.markAllAsTouched();
return;
}
const ticketId = this.escalateForm.get('ticketId')?.value;
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
width: '420px',
data: {
title: 'Confirm Escalation',
message: `Are you sure you want to escalate ticket ${ticketId} to the next bank? This will forward the complaint for further investigation.`,
confirmText: 'Escalate',
cancelText: 'Cancel'
}
});
dialogRef.afterClosed().subscribe((confirmed) => {
if (confirmed) {
this.submitEscalation();
}
});
}
private submitEscalation(): void {
this.isSubmitting = true;
const formVal = this.escalateForm.value;
this.cfcfrmsService
.escalateComplaint(formVal.ticketId, {
ticketId: formVal.ticketId,
nextHopAccountNumber: formVal.nextHopAccountNumber,
nextHopBankIfsc: formVal.nextHopBankIfsc,
remarks: formVal.remarks
})
.subscribe({
next: (result) => {
this.escalationResult = result;
this.isSubmitting = false;
this.snackBar.open('Complaint escalated successfully', 'Close', { duration: 3000 });
},
error: () => {
this.isSubmitting = false;
this.snackBar.open('Failed to escalate complaint', 'Close', { duration: 3000 });
}
});
}
resetForm(): void {
this.escalateForm.reset();
this.lookedUpComplaint = null;
this.escalationResult = null;
}
formatCurrency(amount: number): string {
if (amount == null) return '-';
return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(
amount
);
}
}