WEB-642: Add Loan Originators management (#3095)

Co-authored-by: Jose Alberto Hernandez <alberto@black-box.local>
This commit is contained in:
Jose Alberto Hernandez
2026-02-06 12:15:52 -06:00
committed by GitHub
co-authored by Jose Alberto Hernandez
parent bd2408fcc4
commit 0bc1926199
44 changed files with 1405 additions and 43 deletions
@@ -15,6 +15,7 @@ import { Observable } from 'rxjs';
/** Custom Services */
import { LoansService } from '../loans.service';
import { OrganizationService } from 'app/organization/organization.service';
/**
* Loans notes data resolver.
@@ -22,6 +23,7 @@ import { LoansService } from '../loans.service';
@Injectable()
export class LoanActionButtonResolver {
private loansService = inject(LoansService);
private organizationService = inject(OrganizationService);
/**
* Returns the Loans Notes Data.
@@ -88,6 +90,8 @@ export class LoanActionButtonResolver {
return this.loansService.getLoanActionTemplate(loanId, 'reAge');
} else if (loanActionButton === 'Re-Amortize') {
return this.loansService.getLoanActionTemplate(loanId, 'reAmortization');
} else if (loanActionButton === 'Attach Loan Originator') {
return this.organizationService.getLoanOriginators();
} else {
return undefined;
}
@@ -56,17 +56,6 @@
<input matInput [value]="loanData.availableDisbursementAmountWithOverApplied | formatNumber" readonly />
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Transaction Amount' | translate }}</mat-label>
<input matInput type="number" required formControlName="approvedLoanAmount" />
@if (approveLoanForm.controls.approvedLoanAmount.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transaction Amount' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
@@ -0,0 +1,42 @@
<!--
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="container mat-elevation-z8">
<mat-card>
<form [formGroup]="attachLoanOriginatorForm" (ngSubmit)="submit()">
<mat-card-content>
<div class="layout-column">
<mat-form-field>
<mat-label>{{ 'labels.inputs.Loan Originator' | translate }}</mat-label>
<mat-select formControlName="originatorId">
@for (loanOriginator of loanOriginators; track loanOriginator) {
<mat-option [value]="loanOriginator.id">
{{ loanOriginator.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
<button type="button" mat-raised-button [routerLink]="['../../general']">
{{ 'labels.buttons.Cancel' | translate }}
</button>
<button
mat-raised-button
color="primary"
[disabled]="!attachLoanOriginatorForm.valid"
*mifosxHasPermission="'ATTACH_LOAN_ORIGINATOR'"
>
{{ 'labels.buttons.Submit' | translate }}
</button>
</mat-card-actions>
</mat-card-content>
</form>
</mat-card>
</div>
@@ -0,0 +1,11 @@
/**
* 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/.
*/
.container {
max-width: 37rem;
}
@@ -0,0 +1,81 @@
/**
* 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/.
*/
/** Angular Imports. */
import { Component, Input, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
/** Custom Services. */
import { LoansService } from 'app/loans/loans.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { LoanOriginator } from 'app/loans/models/loan-account.model';
/**
* Attach Loan Originator component.
*/
@Component({
selector: 'mifosx-attach-originator',
templateUrl: './attach-originator.component.html',
styleUrl: './attach-originator.component.scss',
imports: [
...STANDALONE_SHARED_IMPORTS
]
})
export class AttachOriginatorComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
private route = inject(ActivatedRoute);
private loanService = inject(LoansService);
private router = inject(Router);
/** Attach Loan Originator Loan form. */
attachLoanOriginatorForm: UntypedFormGroup;
/** Loan data. */
loanOriginators: LoanOriginator[] = [];
@Input() dataObject: any;
/** Loan Id */
loanId: string | null = null;
constructor() {
this.loanId = this.route.snapshot.params['loanId'];
}
ngOnInit() {
this.setAttachLoanOriginatorForm();
console.log(this.dataObject);
this.loanOriginators = [];
this.dataObject.forEach((loanOriginator: LoanOriginator) => {
if (loanOriginator.status === 'ACTIVE') {
this.loanOriginators.push(loanOriginator);
}
});
}
/**
* Set Attach Loan Originator form.
*/
setAttachLoanOriginatorForm() {
this.attachLoanOriginatorForm = this.formBuilder.group({
originatorId: [
'',
Validators.required
]
});
}
/**
* Submits Approve form.
*/
submit() {
const approveLoanFormData = this.attachLoanOriginatorForm.value;
this.loanService.attachLoanOriginator(this.loanId, approveLoanFormData.originatorId).subscribe((response: any) => {
this.router.navigate(['../../general'], { relativeTo: this.route });
});
}
}
@@ -114,3 +114,6 @@
@if (actions['Undo Write-off']) {
<mifosx-undo-write-off [dataObject]="actionButtonData"></mifosx-undo-write-off>
}
@if (actions['Attach Loan Originator']) {
<mifosx-attach-originator [dataObject]="actionButtonData"></mifosx-attach-originator>
}
@@ -40,6 +40,7 @@ import { LoanReamortizeComponent } from './loan-reamortize/loan-reamortize.compo
import { AddInterestPauseComponent } from './add-interest-pause/add-interest-pause.component';
import { UndoWriteOffComponent } from './undo-write-off/undo-write-off.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { AttachOriginatorComponent } from './attach-originator/attach-originator.component';
/**
* Loan Account Actions component.
@@ -79,7 +80,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
LoanReagingComponent,
LoanReamortizeComponent,
AddInterestPauseComponent,
UndoWriteOffComponent
UndoWriteOffComponent,
AttachOriginatorComponent
]
})
export class LoanAccountActionsComponent {
@@ -130,6 +132,7 @@ export class LoanAccountActionsComponent {
'Contract Termination': boolean;
'Buy Down Fee': boolean;
'Undo Write-off': boolean;
'Attach Loan Originator': boolean;
} = {
Close: false,
'Undo Approval': false,
@@ -169,7 +172,8 @@ export class LoanAccountActionsComponent {
'Capitalized Income': false,
'Contract Termination': false,
'Buy Down Fee': false,
'Undo Write-off': false
'Undo Write-off': false,
'Attach Loan Originator': false
};
actionButtonData: any;
@@ -90,6 +90,11 @@ export class LoansAccountButtonConfiguration {
name: 'Reject',
icon: 'times',
taskPermissionName: 'REJECT_LOAN'
},
{
name: 'Attach Loan Originator',
icon: 'edit',
taskPermissionName: 'ATTACH_LOAN_ORIGINATOR'
}
];
break;
@@ -34,16 +34,35 @@
{{ item.status }}
</td>
</ng-container>
<ng-container matColumnDef="originatorTypeId">
<ng-container matColumnDef="originatorType">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Originator Type' | translate }}</th>
<td mat-cell *matCellDef="let item">
{{ item.originatorTypeId }}
{{ item.originatorType.name }}
</td>
</ng-container>
<ng-container matColumnDef="channelTypeId">
<ng-container matColumnDef="channelType">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Channel Type' | translate }}</th>
<td mat-cell *matCellDef="let item">
{{ item.channelTypeId }}
{{ item.channelType.name }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let loanOriginator">
@if (loanStatus.pendingApproval) {
<button
type="button"
color="warn"
title="{{ 'tooltips.Dettach' | translate }}"
mat-icon-button
matTooltip="{{ 'tooltips.Delete' | translate }}"
*mifosxHasPermission="'DETTACH_LOAN_ORIGINATOR'"
matTooltipPosition="above"
(click)="dettachLoanOriginator(loanOriginator)"
>
<fa-icon icon="trash" size="lg"></fa-icon>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="loanoriginatorsColumns"></tr>
@@ -6,6 +6,8 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import {
MatCell,
MatCellDef,
@@ -18,8 +20,13 @@ import {
MatRowDef,
MatTable
} from '@angular/material/table';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { TranslateService } from '@ngx-translate/core';
import { LoansService } from 'app/loans/loans.service';
import { LoanOriginator } from 'app/loans/models/loan-account.model';
import { LoanStatus } from 'app/loans/models/loan-status.model';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
@@ -37,29 +44,73 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
MatRow,
MatIconButton,
FaIconComponent
]
})
export class LoanOriginatorsTabComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private loansService = inject(LoansService);
private translateService = inject(TranslateService);
private dialog = inject(MatDialog);
loanOriginatorsData: LoanOriginator[] = [];
loanId: number | null = null;
loanId: string | null = null;
clientId: string | null = null;
loanStatus: LoanStatus | null = null;
loanoriginatorsColumns: string[] = [
'id',
'externalId',
'name',
'status',
'originatorTypeId',
'channelTypeId'
'originatorType',
'channelType',
'actions'
];
constructor() {
const loanIdParam = this.route.parent?.parent?.snapshot.paramMap.get('loanId');
this.loanId = loanIdParam ? Number(loanIdParam) : null;
this.clientId = this.route.parent.parent.snapshot.paramMap.get('clientId');
this.loanId = this.route.parent?.parent?.snapshot.paramMap.get('loanId');
this.route.parent.parent.data.subscribe((data: { loanDetailsData: any }) => {
this.loanStatus = data.loanDetailsData.status;
});
this.route.parent.data.subscribe((data: { loanOriginatorsData: any }) => {
this.loanOriginatorsData = data.loanOriginatorsData.originators;
});
}
dettachLoanOriginator(loanOriginator: LoanOriginator): void {
const dettachCodeDialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: this.translateService.instant('labels.heading.Loan Originators'),
dialogContext:
this.translateService.instant('labels.buttons.Delete') +
' ' +
this.translateService.instant('labels.inputs.Loan Originator') +
' ' +
loanOriginator.name
}
});
dettachCodeDialogRef.afterClosed().subscribe((response: any) => {
if (response.confirm) {
this.loansService.dettachLoanOriginator(this.loanId, String(loanOriginator.id)).subscribe((response) => {
this.reload();
});
}
});
}
/**
* Refetches data fot the component
*/
private reload() {
const url: string = this.router.url;
this.router
.navigateByUrl(`/clients/${this.clientId}/loans-accounts`, { skipLocationChange: true })
.then(() => this.router.navigate([url]));
}
}
+10
View File
@@ -658,6 +658,16 @@ export class LoansService {
return this.http.post('/loans?command=calculateLoanSchedule', payload);
}
attachLoanOriginator(loanId: string, originatorId: string): Observable<any> {
const emptyBody = {};
return this.http.post(`/loans/${loanId}/originators/${originatorId}`, emptyBody);
}
dettachLoanOriginator(loanId: string, originatorId: string): Observable<any> {
const emptyBody = {};
return this.http.delete(`/loans/${loanId}/originators/${originatorId}`, emptyBody);
}
/**
* @param loansAccount Loan account data used for the request
* @param loansAccountTemplate Loan account template for getting product default values
+3 -3
View File
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Currency } from 'app/shared/models/general.model';
import { CodeValue, Currency } from 'app/shared/models/general.model';
export interface DelinquencyRange {
id: number;
@@ -178,6 +178,6 @@ export interface LoanOriginator {
externalId: string;
name: string;
status: string;
originatorTypeId: number;
channelTypeId: number;
originatorType: CodeValue;
channelType: CodeValue;
}