223 lines
7.2 KiB
TypeScript
223 lines
7.2 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, inject } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { MatInputModule } from '@angular/material/input';
|
|
import { MatSelectModule } from '@angular/material/select';
|
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
|
|
|
import { AmlService } from 'app/aml/aml.service';
|
|
|
|
export interface RiskChangeDialogData {
|
|
clientId: number;
|
|
customerName: string;
|
|
/** 1 Low, 2 Medium, 3 High; null where the customer carries no rating yet. */
|
|
currentCategory: number | null;
|
|
/** What the report's own computation says the rating should be, used as the default. */
|
|
recommendedCategory: number | null;
|
|
/** The one-line explanation the report already shows against the row. */
|
|
recommendationReason: string;
|
|
}
|
|
|
|
/**
|
|
* Raises a change to a customer's KYC risk category for approval.
|
|
*
|
|
* <p>
|
|
* The rating governs how often the customer is re-KYC'd and what AML thresholds they are measured
|
|
* against, so it is not something one officer moves on their own. The dialog collects what it
|
|
* should become and why; a checker applies it.
|
|
*/
|
|
@Component({
|
|
selector: 'mifosx-risk-change-dialog',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
ReactiveFormsModule,
|
|
MatDialogModule,
|
|
MatButtonModule,
|
|
MatFormFieldModule,
|
|
MatInputModule,
|
|
MatSelectModule
|
|
],
|
|
template: `
|
|
<h2 mat-dialog-title>Change Risk Category</h2>
|
|
<mat-dialog-content>
|
|
<div class="rc-summary">
|
|
<div class="rc-row">
|
|
<span class="rc-label">Customer</span>
|
|
<strong class="rc-value">{{ data.customerName }}</strong>
|
|
</div>
|
|
<div class="rc-row">
|
|
<span class="rc-label">Rating on file</span>
|
|
<strong class="rc-value">{{ labelOf(data.currentCategory) }}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- The report already worked this out; showing it saves the reviewer re-deriving it, and
|
|
says plainly that the figure is a recommendation rather than a decision. -->
|
|
<p class="rc-reason" *ngIf="data.recommendationReason">
|
|
<span class="rc-label">Why this came up</span><br />{{ data.recommendationReason }}
|
|
</p>
|
|
|
|
<form [formGroup]="form" class="rc-form">
|
|
<mat-form-field appearance="outline" class="rc-full">
|
|
<mat-label>New risk category</mat-label>
|
|
<mat-select formControlName="requestedCategory">
|
|
<mat-option [value]="1">Low</mat-option>
|
|
<mat-option [value]="2">Medium</mat-option>
|
|
<mat-option [value]="3">High</mat-option>
|
|
</mat-select>
|
|
<mat-error *ngIf="form.get('requestedCategory')?.hasError('required')">Pick the new category</mat-error>
|
|
</mat-form-field>
|
|
|
|
<mat-form-field appearance="outline" class="rc-full">
|
|
<mat-label>Reason</mat-label>
|
|
<textarea
|
|
matInput
|
|
formControlName="reason"
|
|
rows="3"
|
|
placeholder="e.g. Repeated large cash deposits inconsistent with declared income"
|
|
></textarea>
|
|
<mat-hint>Recorded against the customer's risk history</mat-hint>
|
|
<mat-error *ngIf="form.get('reason')?.hasError('required')">A reason is required</mat-error>
|
|
</mat-form-field>
|
|
</form>
|
|
|
|
<p class="rc-effect">
|
|
The rating does not move now. It goes to a checker, and on approval the customer's KYC record and re-KYC due
|
|
date are updated together.
|
|
</p>
|
|
|
|
<p class="rc-error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
|
</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="primary" type="button" [disabled]="form.invalid || submitting" (click)="submit()">
|
|
{{ submitting ? 'Sending…' : 'Send for Approval' }}
|
|
</button>
|
|
</mat-dialog-actions>
|
|
`,
|
|
styles: [
|
|
`
|
|
.rc-summary {
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
padding: 12px 14px;
|
|
margin-bottom: 14px;
|
|
background: #f8fafc;
|
|
}
|
|
.rc-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: baseline;
|
|
gap: 16px;
|
|
padding: 3px 0;
|
|
}
|
|
.rc-label {
|
|
color: #64748b;
|
|
font-size: 12.5px;
|
|
}
|
|
.rc-value {
|
|
color: #1e293b;
|
|
font-size: 13.5px;
|
|
}
|
|
.rc-reason {
|
|
font-size: 12.5px;
|
|
line-height: 1.5;
|
|
color: #334155;
|
|
margin: 0 0 14px;
|
|
}
|
|
.rc-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.rc-full {
|
|
width: 100%;
|
|
}
|
|
.rc-effect {
|
|
font-size: 12.5px;
|
|
line-height: 1.55;
|
|
color: #1e3a8a;
|
|
background: #eff6ff;
|
|
border: 1px solid #bfdbfe;
|
|
border-radius: 6px;
|
|
padding: 10px 12px;
|
|
margin: 4px 0 0;
|
|
}
|
|
.rc-error {
|
|
margin: 12px 0 0;
|
|
padding: 10px 12px;
|
|
border: 1px solid #fecaca;
|
|
border-radius: 6px;
|
|
background: #fef2f2;
|
|
color: #b91c1c;
|
|
font-size: 12.5px;
|
|
}
|
|
`
|
|
]
|
|
})
|
|
export class RiskChangeDialogComponent {
|
|
private fb = inject(FormBuilder);
|
|
private amlService = inject(AmlService);
|
|
private snackBar = inject(MatSnackBar);
|
|
dialogRef = inject(MatDialogRef<RiskChangeDialogComponent>);
|
|
|
|
form: FormGroup;
|
|
submitting = false;
|
|
errorMessage = '';
|
|
|
|
constructor(@Inject(MAT_DIALOG_DATA) public data: RiskChangeDialogData) {
|
|
this.form = this.fb.group({
|
|
// Defaulted to what the report recommends, but left editable: the reviewer has seen the
|
|
// customer and the computation has not.
|
|
requestedCategory: [
|
|
data.recommendedCategory ?? null,
|
|
Validators.required
|
|
],
|
|
reason: [
|
|
'',
|
|
Validators.required
|
|
]
|
|
});
|
|
}
|
|
|
|
labelOf(category: number | null): string {
|
|
return category === 1 ? 'Low' : category === 2 ? 'Medium' : category === 3 ? 'High' : 'Unrated';
|
|
}
|
|
|
|
submit(): void {
|
|
if (this.form.invalid) {
|
|
this.form.markAllAsTouched();
|
|
return;
|
|
}
|
|
this.submitting = true;
|
|
this.errorMessage = '';
|
|
const v = this.form.value;
|
|
this.amlService.requestRiskChange(this.data.clientId, v.requestedCategory, (v.reason || '').trim()).subscribe({
|
|
next: () => {
|
|
this.submitting = false;
|
|
this.snackBar.open(
|
|
`Risk change to ${this.labelOf(v.requestedCategory)} sent for approval — ${this.data.customerName}`,
|
|
'Close',
|
|
{ duration: 5000 }
|
|
);
|
|
this.dialogRef.close(true);
|
|
},
|
|
error: (err: any) => {
|
|
this.submitting = false;
|
|
this.errorMessage = err?.error?.defaultUserMessage || err?.error?.message || 'Could not send the request.';
|
|
}
|
|
});
|
|
}
|
|
}
|