Files
cbsnexora/src/app/risk-review/risk-change-approvals.component.ts
T

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">&rarr;</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 });
}
});
}
}