/** * 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 = { 1: 'Low', 2: 'Medium', 3: 'High' }; /** * The approval queue for risk-category changes raised from the Risk Review Report. * *

* 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: `

Risk Category Approvals

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.

Req. ID Customer CIF Branch Rating change Reason Status Requested By Requested On Action
{{ r.id }} {{ r.clientName }} {{ r.cifNumber || '—' }} {{ r.officeName || '—' }} {{ labelOf(r.currentCategory) }} → {{ labelOf(r.requestedCategory) }} {{ r.reason }} {{ r.status }} {{ r.makerUsername }} {{ r.createdDate }}
by {{ r.checkerUsername }}, {{ r.checkerDate }} {{ r.rejectionReason }}
No risk category change matches this filter.
`, 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: `

Reject Risk Category Change

{{ data.request.clientName }} stays on their current rating. The reason is shown to the reviewer who raised it.

Rejection reason A reason is required
`, 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); 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 }); } }); } }