402 lines
11 KiB
TypeScript
402 lines
11 KiB
TypeScript
/**
|
|
* 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, OnInit, inject } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
|
|
import { AmlService } from 'app/aml/aml.service';
|
|
|
|
const CATEGORY_LABEL: Record<number, string> = { 1: 'Low', 2: 'Medium', 3: 'High' };
|
|
|
|
/**
|
|
* The approval queue for risk-category changes raised from the Risk Review Report.
|
|
*
|
|
* <p>
|
|
* Approving here moves the customer's KYC rating and their re-KYC due date together, so the row
|
|
* shows where the rating is going from and to, and why the reviewer asked - the checker is deciding
|
|
* how closely the bank watches that customer, not clearing a queue item.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-risk-change-approvals',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
MatButtonModule,
|
|
MatDialogModule,
|
|
MatSnackBarModule
|
|
],
|
|
template: `
|
|
<div class="rca-root">
|
|
<div class="rca-head">
|
|
<div>
|
|
<h2 class="rca-title">Risk Category Approvals</h2>
|
|
<p class="rca-sub">
|
|
Changes to a customer's KYC risk category, raised from the Risk Review Report. Approving moves the rating
|
|
and the re-KYC due date together, and writes the reason into the customer's risk history.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rca-filters">
|
|
<button
|
|
*ngFor="let s of statuses"
|
|
type="button"
|
|
class="rca-chip"
|
|
[class.rca-chip--on]="selectedStatus === s"
|
|
(click)="filterBy(s)"
|
|
>
|
|
{{ s === '' ? 'All' : s }} <span class="rca-count">({{ countOf(s) }})</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="rca-table-wrap">
|
|
<table class="rca-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Req. ID</th>
|
|
<th>Customer</th>
|
|
<th>CIF</th>
|
|
<th>Branch</th>
|
|
<th>Rating change</th>
|
|
<th>Reason</th>
|
|
<th>Status</th>
|
|
<th>Requested By</th>
|
|
<th>Requested On</th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr *ngFor="let r of filtered">
|
|
<td class="mono">{{ r.id }}</td>
|
|
<td>
|
|
<strong>{{ r.clientName }}</strong>
|
|
</td>
|
|
<td class="mono">{{ r.cifNumber || '—' }}</td>
|
|
<td>{{ r.officeName || '—' }}</td>
|
|
<td>
|
|
<span class="rca-move">
|
|
{{ labelOf(r.currentCategory) }} <span class="rca-arrow">→</span>
|
|
<strong>{{ labelOf(r.requestedCategory) }}</strong>
|
|
</span>
|
|
</td>
|
|
<td class="rca-reason">{{ r.reason }}</td>
|
|
<td>
|
|
<span class="rca-status" [ngClass]="'rca-status--' + (r.status || '').toLowerCase()">{{
|
|
r.status
|
|
}}</span>
|
|
</td>
|
|
<td>{{ r.makerUsername }}</td>
|
|
<td class="mono">{{ r.createdDate }}</td>
|
|
<td>
|
|
<div class="rca-actions" *ngIf="r.status === 'PENDING'">
|
|
<button mat-flat-button color="primary" type="button" (click)="approve(r)">Approve</button>
|
|
<button mat-stroked-button color="warn" type="button" (click)="reject(r)">Reject</button>
|
|
</div>
|
|
<span class="rca-done" *ngIf="r.status === 'APPROVED'">
|
|
by {{ r.checkerUsername }}, {{ r.checkerDate }}
|
|
</span>
|
|
<span class="rca-done" *ngIf="r.status === 'REJECTED'">{{ r.rejectionReason }}</span>
|
|
</td>
|
|
</tr>
|
|
<tr *ngIf="!filtered.length">
|
|
<td colspan="10" class="rca-empty">No risk category change matches this filter.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
`,
|
|
styles: [
|
|
`
|
|
.rca-root {
|
|
padding: 20px 24px 40px;
|
|
background: #f8fafc;
|
|
min-height: 100%;
|
|
}
|
|
.rca-head {
|
|
margin-bottom: 16px;
|
|
}
|
|
.rca-title {
|
|
margin: 0 0 3px;
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
color: #1e293b;
|
|
}
|
|
.rca-sub {
|
|
margin: 0;
|
|
max-width: 800px;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
color: #64748b;
|
|
}
|
|
.rca-filters {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 14px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.rca-chip {
|
|
height: 32px;
|
|
padding: 0 14px;
|
|
border: 1px solid #cbd5e1;
|
|
border-radius: 999px;
|
|
background: #fff;
|
|
font: inherit;
|
|
font-size: 12.5px;
|
|
font-weight: 600;
|
|
color: #334155;
|
|
cursor: pointer;
|
|
}
|
|
.rca-chip--on {
|
|
border-color: #1e3a8a;
|
|
background: #eff6ff;
|
|
color: #1e3a8a;
|
|
}
|
|
.rca-count {
|
|
color: #94a3b8;
|
|
font-weight: 500;
|
|
}
|
|
.rca-table-wrap {
|
|
background: #fff;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 11px;
|
|
overflow-x: auto;
|
|
}
|
|
.rca-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 12.5px;
|
|
}
|
|
.rca-table th {
|
|
text-align: left;
|
|
padding: 10px;
|
|
background: #f1f5f9;
|
|
font-size: 10.5px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
color: #475569;
|
|
white-space: nowrap;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
.rca-table td {
|
|
padding: 11px 10px;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
vertical-align: middle;
|
|
color: #334155;
|
|
}
|
|
.mono {
|
|
font-family: 'DM Mono', monospace;
|
|
}
|
|
.rca-move {
|
|
white-space: nowrap;
|
|
}
|
|
.rca-arrow {
|
|
color: #94a3b8;
|
|
margin: 0 4px;
|
|
}
|
|
.rca-reason {
|
|
max-width: 280px;
|
|
}
|
|
.rca-status {
|
|
display: inline-block;
|
|
padding: 2px 9px;
|
|
border-radius: 999px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
background: #f1f5f9;
|
|
color: #475569;
|
|
}
|
|
.rca-status--pending {
|
|
background: #fef3c7;
|
|
color: #b45309;
|
|
}
|
|
.rca-status--approved {
|
|
background: #dcfce7;
|
|
color: #15803d;
|
|
}
|
|
.rca-status--rejected {
|
|
background: #fee2e2;
|
|
color: #b91c1c;
|
|
}
|
|
.rca-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.rca-done {
|
|
font-size: 11.5px;
|
|
color: #64748b;
|
|
}
|
|
.rca-empty {
|
|
padding: 30px;
|
|
text-align: center;
|
|
color: #94a3b8;
|
|
}
|
|
`
|
|
]
|
|
})
|
|
export class RiskChangeApprovalsComponent implements OnInit {
|
|
private amlService = inject(AmlService);
|
|
private dialog = inject(MatDialog);
|
|
private snackBar = inject(MatSnackBar);
|
|
|
|
rows: any[] = [];
|
|
readonly statuses = [
|
|
'PENDING',
|
|
'APPROVED',
|
|
'REJECTED',
|
|
''
|
|
];
|
|
selectedStatus = 'PENDING';
|
|
|
|
ngOnInit(): void {
|
|
this.load();
|
|
}
|
|
|
|
load(): void {
|
|
this.amlService.getRiskChangeRequests().subscribe({
|
|
next: (rows) => (this.rows = rows || []),
|
|
error: (err: any) =>
|
|
this.snackBar.open('Could not load requests: ' + (err?.error?.defaultUserMessage || err.message), 'Close', {
|
|
duration: 5000
|
|
})
|
|
});
|
|
}
|
|
|
|
get filtered(): any[] {
|
|
return this.selectedStatus ? this.rows.filter((r) => r.status === this.selectedStatus) : this.rows;
|
|
}
|
|
|
|
countOf(status: string): number {
|
|
return status ? this.rows.filter((r) => r.status === status).length : this.rows.length;
|
|
}
|
|
|
|
filterBy(status: string): void {
|
|
this.selectedStatus = status;
|
|
}
|
|
|
|
labelOf(category: number | null): string {
|
|
return category == null ? 'Unrated' : CATEGORY_LABEL[category] || '—';
|
|
}
|
|
|
|
approve(r: any): void {
|
|
this.amlService.approveRiskChange(r.id).subscribe({
|
|
next: () => {
|
|
this.snackBar.open(
|
|
`${r.clientName} moved to ${this.labelOf(r.requestedCategory)} — re-KYC due date updated.`,
|
|
'Close',
|
|
{ duration: 6000 }
|
|
);
|
|
this.load();
|
|
},
|
|
error: (err: any) =>
|
|
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 7000 })
|
|
});
|
|
}
|
|
|
|
reject(r: any): void {
|
|
this.dialog
|
|
.open(RejectRiskChangeDialogComponent, { width: '450px', data: { request: r }, disableClose: true })
|
|
.afterClosed()
|
|
.subscribe((done) => {
|
|
if (done) {
|
|
this.load();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── REJECT DIALOG ──
|
|
@Component({
|
|
selector: 'mifosx-reject-risk-change-dialog',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
ReactiveFormsModule,
|
|
MatDialogModule,
|
|
MatFormFieldModule,
|
|
MatInputModule,
|
|
MatButtonModule
|
|
],
|
|
template: `
|
|
<h2 mat-dialog-title>Reject Risk Category Change</h2>
|
|
<mat-dialog-content>
|
|
<p class="hint">
|
|
{{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it.
|
|
</p>
|
|
<form [formGroup]="form">
|
|
<mat-form-field appearance="outline" class="full">
|
|
<mat-label>Rejection reason</mat-label>
|
|
<textarea matInput formControlName="rejectionReason" rows="3"></textarea>
|
|
<mat-error *ngIf="form.get('rejectionReason')?.hasError('required')">A reason is required</mat-error>
|
|
</mat-form-field>
|
|
</form>
|
|
</mat-dialog-content>
|
|
<mat-dialog-actions align="end">
|
|
<button mat-stroked-button type="button" (click)="dialogRef.close(false)">Cancel</button>
|
|
<button mat-flat-button color="warn" type="button" [disabled]="form.invalid || busy" (click)="submit()">
|
|
Reject
|
|
</button>
|
|
</mat-dialog-actions>
|
|
`,
|
|
styles: [
|
|
`
|
|
.full {
|
|
width: 100%;
|
|
}
|
|
.hint {
|
|
font-size: 13px;
|
|
color: #64748b;
|
|
margin: 0 0 12px;
|
|
line-height: 1.5;
|
|
}
|
|
`
|
|
]
|
|
})
|
|
export class RejectRiskChangeDialogComponent {
|
|
private fb = inject(FormBuilder);
|
|
private amlService = inject(AmlService);
|
|
private snackBar = inject(MatSnackBar);
|
|
dialogRef = inject(MatDialogRef<RejectRiskChangeDialogComponent>);
|
|
|
|
data: { request: any } = inject(MAT_DIALOG_DATA);
|
|
form: FormGroup;
|
|
busy = false;
|
|
|
|
constructor() {
|
|
this.form = this.fb.group({ rejectionReason: [
|
|
'',
|
|
Validators.required
|
|
] });
|
|
}
|
|
|
|
submit(): void {
|
|
if (this.form.invalid) {
|
|
return;
|
|
}
|
|
this.busy = true;
|
|
this.amlService.rejectRiskChange(this.data.request.id, this.form.value.rejectionReason).subscribe({
|
|
next: () => {
|
|
this.busy = false;
|
|
this.snackBar.open('Risk category change rejected', 'Close', { duration: 3000 });
|
|
this.dialogRef.close(true);
|
|
},
|
|
error: (err: any) => {
|
|
this.busy = false;
|
|
this.snackBar.open('Error: ' + (err?.error?.defaultUserMessage || err.message), 'Close', { duration: 6000 });
|
|
}
|
|
});
|
|
}
|
|
}
|