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:
co-authored by
Claude Opus 4.6
parent
0a32d7135e
commit
b3a4cce0cf
@@ -0,0 +1,162 @@
|
||||
<!--
|
||||
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="complaint-list-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>{{ 'labels.menus.Complaint Management' | translate }}</h2>
|
||||
<p class="subtitle">{{ 'labels.text.I4C/MHA CFCFRMS Complaint Tickets' | translate }}</p>
|
||||
</div>
|
||||
<button mat-raised-button color="accent" (click)="exportCsv()" [disabled]="complaints.length === 0">
|
||||
<fa-icon icon="file-csv" class="mr-05"></fa-icon> {{ 'labels.text.Export CSV' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Chips -->
|
||||
<div class="filter-chips">
|
||||
<button mat-stroked-button [class.active-chip]="activeChip === 'ALL'" (click)="onChipClick('ALL')">
|
||||
{{ 'labels.text.All' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button [class.active-chip]="activeChip === 'PENDING'" (click)="onChipClick('PENDING')">
|
||||
<span class="chip-dot pending"></span> {{ 'labels.text.Pending' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button [class.active-chip]="activeChip === 'ACTIVE'" (click)="onChipClick('ACTIVE')">
|
||||
<span class="chip-dot active"></span> {{ 'labels.text.Active' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button [class.active-chip]="activeChip === 'RESOLVED'" (click)="onChipClick('RESOLVED')">
|
||||
<span class="chip-dot resolved"></span> {{ 'labels.text.Resolved' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<mat-card class="filter-bar">
|
||||
<mat-card-content>
|
||||
<div class="filter-row">
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>{{ 'labels.text.Search by Ticket ID / Mobile' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="searchQuery"
|
||||
(keyup.enter)="onSearch()"
|
||||
placeholder="{{ 'labels.text.Enter ticket ID or mobile...' | translate }}"
|
||||
/>
|
||||
<button mat-icon-button matSuffix (click)="onSearch()">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>{{ 'labels.text.Status' | translate }}</mat-label>
|
||||
<mat-select [(ngModel)]="statusFilter" (selectionChange)="onStatusFilterChange()">
|
||||
<mat-option value="">{{ 'labels.text.All Statuses' | translate }}</mat-option>
|
||||
@for (s of statuses; track s) {
|
||||
<mat-option [value]="s">{{ s.replace('_', ' ') }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>{{ 'labels.text.From Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="fromPicker" [(ngModel)]="dateFrom" (dateChange)="onSearch()" />
|
||||
<mat-datepicker-toggle matSuffix [for]="fromPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #fromPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="filter-field">
|
||||
<mat-label>{{ 'labels.text.To Date' | translate }}</mat-label>
|
||||
<input matInput [matDatepicker]="toPicker" [(ngModel)]="dateTo" (dateChange)="onSearch()" />
|
||||
<mat-datepicker-toggle matSuffix [for]="toPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #toPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-icon-button matTooltip="{{ 'labels.text.Clear Filters' | translate }}" (click)="clearFilters()">
|
||||
<fa-icon icon="times-circle"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Table -->
|
||||
<mat-card class="table-card">
|
||||
<mat-card-content>
|
||||
@if (isLoading) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
</div>
|
||||
} @else if (complaints.length === 0) {
|
||||
<div class="empty-state">
|
||||
<fa-icon icon="inbox" size="3x"></fa-icon>
|
||||
<p>{{ 'labels.text.No complaints match your filters' | translate }}</p>
|
||||
<button mat-stroked-button (click)="clearFilters()">{{ 'labels.text.Clear Filters' | translate }}</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="table-responsive">
|
||||
<table mat-table [dataSource]="complaints" class="full-width">
|
||||
<ng-container matColumnDef="ticketId">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Ticket ID' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<a [routerLink]="['/cfcfrms/complaints', row.ticketId]" class="ticket-link">{{ row.ticketId }}</a>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="complainantName">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Complainant' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.complainantName }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="complainantMobile">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Mobile' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.complainantMobile }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="fraudAmount">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Fraud Amount' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row" class="amount-cell">{{ formatCurrency(row.fraudAmount) }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="suspectAccountNumber">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Suspect Account' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row" class="mono">{{ row.suspectAccountNumber }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Status' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<cfcfrms-status-badge [status]="row.status"></cfcfrms-status-badge>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="createdAt">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Created At' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">{{ formatDate(row.createdAt) }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ 'labels.text.Actions' | translate }}</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<a
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
[routerLink]="['/cfcfrms/complaints', row.ticketId]"
|
||||
matTooltip="{{ 'labels.text.View Details' | translate }}"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</a>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||
</table>
|
||||
</div>
|
||||
<mat-paginator
|
||||
[length]="totalElements"
|
||||
[pageSize]="pageSize"
|
||||
[pageIndex]="pageIndex"
|
||||
[pageSizeOptions]="[10, 25, 50]"
|
||||
(page)="onPageChange($event)"
|
||||
showFirstLastButtons
|
||||
>
|
||||
</mat-paginator>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
.complaint-list-page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
button {
|
||||
border-radius: 20px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.active-chip {
|
||||
background: #1e88e5 !important;
|
||||
color: #fff !important;
|
||||
border-color: #1e88e5 !important;
|
||||
}
|
||||
|
||||
.chip-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
|
||||
&.pending {
|
||||
background: #f59e0b;
|
||||
}
|
||||
&.active {
|
||||
background: #1e88e5;
|
||||
}
|
||||
&.resolved {
|
||||
background: #10b981;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 8px !important;
|
||||
|
||||
mat-card-content {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.filter-field {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: 8px !important;
|
||||
|
||||
mat-card-content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ticket-link {
|
||||
color: #1e88e5;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-cell {
|
||||
font-weight: 600;
|
||||
color: #0d1b2a;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 64px;
|
||||
color: #94a3b8;
|
||||
|
||||
p {
|
||||
margin: 12px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.mr-05 {
|
||||
margin-right: 4px;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 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, ViewChild, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
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 { Complaint, ComplaintStatus } from '../models/cfcfrms.models';
|
||||
|
||||
type FilterChip = 'ALL' | 'PENDING' | 'ACTIVE' | 'RESOLVED';
|
||||
|
||||
@Component({
|
||||
selector: 'cfcfrms-complaint-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
FormsModule,
|
||||
MatCardModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatChipsModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDatepickerModule,
|
||||
MatNativeDateModule,
|
||||
MatSnackBarModule,
|
||||
MatTooltipModule,
|
||||
FaIconComponent,
|
||||
TranslateModule,
|
||||
StatusBadgeComponent
|
||||
],
|
||||
templateUrl: './complaint-list.component.html',
|
||||
styleUrls: ['./complaint-list.component.scss']
|
||||
})
|
||||
export class ComplaintListComponent implements OnInit {
|
||||
private cfcfrmsService = inject(CfcfrmsService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
|
||||
complaints: Complaint[] = [];
|
||||
totalElements = 0;
|
||||
pageSize = 10;
|
||||
pageIndex = 0;
|
||||
isLoading = true;
|
||||
|
||||
searchQuery = '';
|
||||
statusFilter = '';
|
||||
dateFrom: Date | null = null;
|
||||
dateTo: Date | null = null;
|
||||
activeChip: FilterChip = 'ALL';
|
||||
|
||||
displayedColumns = [
|
||||
'ticketId',
|
||||
'complainantName',
|
||||
'complainantMobile',
|
||||
'fraudAmount',
|
||||
'suspectAccountNumber',
|
||||
'status',
|
||||
'createdAt',
|
||||
'actions'
|
||||
];
|
||||
|
||||
statuses: ComplaintStatus[] = [
|
||||
'RECEIVED',
|
||||
'UNDER_REVIEW',
|
||||
'LIEN_MARKED',
|
||||
'DEBIT_FROZEN',
|
||||
'FUNDS_MOVED',
|
||||
'RESOLVED',
|
||||
'REJECTED'
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadComplaints();
|
||||
}
|
||||
|
||||
loadComplaints(): void {
|
||||
this.isLoading = true;
|
||||
const params: any = {
|
||||
page: this.pageIndex,
|
||||
size: this.pageSize
|
||||
};
|
||||
if (this.searchQuery) params.search = this.searchQuery;
|
||||
if (this.statusFilter) params.status = this.statusFilter;
|
||||
if (this.dateFrom) params.dateFrom = this.dateFrom.toISOString().split('T')[0];
|
||||
if (this.dateTo) params.dateTo = this.dateTo.toISOString().split('T')[0];
|
||||
|
||||
this.cfcfrmsService.getComplaints(params).subscribe({
|
||||
next: (res) => {
|
||||
this.complaints = res.content;
|
||||
this.totalElements = res.totalElements;
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.complaints = [];
|
||||
this.totalElements = 0;
|
||||
this.isLoading = false;
|
||||
this.snackBar.open('Failed to load complaints', 'Close', { duration: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onPageChange(event: PageEvent): void {
|
||||
this.pageIndex = event.pageIndex;
|
||||
this.pageSize = event.pageSize;
|
||||
this.loadComplaints();
|
||||
}
|
||||
|
||||
onSearch(): void {
|
||||
this.pageIndex = 0;
|
||||
this.loadComplaints();
|
||||
}
|
||||
|
||||
onStatusFilterChange(): void {
|
||||
this.pageIndex = 0;
|
||||
this.activeChip = 'ALL';
|
||||
this.loadComplaints();
|
||||
}
|
||||
|
||||
onChipClick(chip: FilterChip): void {
|
||||
this.activeChip = chip;
|
||||
switch (chip) {
|
||||
case 'ALL':
|
||||
this.statusFilter = '';
|
||||
break;
|
||||
case 'PENDING':
|
||||
this.statusFilter = 'RECEIVED,UNDER_REVIEW';
|
||||
break;
|
||||
case 'ACTIVE':
|
||||
this.statusFilter = 'LIEN_MARKED,DEBIT_FROZEN,FUNDS_MOVED';
|
||||
break;
|
||||
case 'RESOLVED':
|
||||
this.statusFilter = 'RESOLVED,REJECTED';
|
||||
break;
|
||||
}
|
||||
this.pageIndex = 0;
|
||||
this.loadComplaints();
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.searchQuery = '';
|
||||
this.statusFilter = '';
|
||||
this.dateFrom = null;
|
||||
this.dateTo = null;
|
||||
this.activeChip = 'ALL';
|
||||
this.pageIndex = 0;
|
||||
this.loadComplaints();
|
||||
}
|
||||
|
||||
exportCsv(): void {
|
||||
const headers = [
|
||||
'Ticket ID',
|
||||
'Complainant',
|
||||
'Mobile',
|
||||
'Fraud Amount',
|
||||
'Suspect Account',
|
||||
'Status',
|
||||
'Created At'
|
||||
];
|
||||
const rows = this.complaints.map((c) => [
|
||||
c.ticketId,
|
||||
c.complainantName,
|
||||
c.complainantMobile,
|
||||
c.fraudAmount.toString(),
|
||||
c.suspectAccountNumber,
|
||||
c.status,
|
||||
c.createdAt
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map((r) => r.join(','))
|
||||
].join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `cfcfrms_complaints_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
this.snackBar.open('CSV exported successfully', 'Close', { duration: 3000 });
|
||||
}
|
||||
|
||||
formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 }).format(
|
||||
amount
|
||||
);
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user