Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ export class LoanActionButtonResolver {
} else if (loanActionButton === 'Disburse to Savings') {
return this.loansService.getLoanActionTemplate(loanId, 'disburseToSavings');
} else if (loanActionButton === 'Recovery Payment') {
return this.loansService.getLoanActionTemplate(loanId, 'recoverypayment');
return this.loanProductService.isLoanProduct
? this.loansService.getLoanActionTemplate(loanId, 'recoverypayment')
: this.loansService.getWorkingCapitalLoanActionTemplate(loanId, 'recoveryPayment');
} else if (loanActionButton === 'View Guarantors') {
return this.loansService.getGuarantors(loanId).pipe(catchError(() => of([])));
} else if (loanActionButton === 'Create Guarantor') {
Expand Down
12 changes: 12 additions & 0 deletions src/app/loans/loans-view/general-tab/general-tab.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,18 @@ <h3 class="section-title">{{ 'labels.menus.Write-Off' | translate }}</h3>
</div>
}

@if (loanProductService.isWorkingCapital && loanDetails.writtenOffOnDate) {
<div class="section">
<div class="section-head">
<h3 class="section-title">{{ 'labels.heading.Recovery' | translate }}</h3>
</div>
<mifosx-working-capital-recovery-panel
[balance]="loanDetails.balance"
[currencyCode]="currencyCode"
></mifosx-working-capital-recovery-panel>
</div>
}

@if (hasNumberOfRepayments() || loanDetails.timeline?.expectedMaturityDate) {
<div class="section">
<div class="section-head">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { LoanProductService } from 'app/products/loan-products/services/loan-product.service';
import { LoanProductBaseComponent } from 'app/products/loan-products/common/loan-product-base.component';
import { LoanSummaryBalanceComponentComponent } from './loan-summary-balance-component/loan-summary-balance-component.component';
import { WorkingCapitalRecoveryPanelComponent } from '../working-capital/loan-recovery-panel/loan-recovery-panel.component';

@Component({
selector: 'mifosx-general-tab',
Expand All @@ -29,7 +30,8 @@ import { LoanSummaryBalanceComponentComponent } from './loan-summary-balance-com
CurrencyPipe,
DateFormatPipe,
FormatNumberPipe,
LoanSummaryBalanceComponentComponent
LoanSummaryBalanceComponentComponent,
WorkingCapitalRecoveryPanelComponent
],
changeDetection: ChangeDetectionStrategy.OnPush
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@
<mifosx-loan-reschedule [dataObject]="actionButtonData"></mifosx-loan-reschedule>
}
@if (actions['Recovery Payment']) {
<mifosx-recovery-repayment [dataObject]="actionButtonData"></mifosx-recovery-repayment>
@if (isWorkingCapital) {
<mifosx-working-capital-recovery-payment [dataObject]="actionButtonData"></mifosx-working-capital-recovery-payment>
} @else {
<mifosx-recovery-repayment [dataObject]="actionButtonData"></mifosx-recovery-repayment>
}
}
@if (actions['View Guarantors']) {
<mifosx-view-guarantors [dataObject]="actionButtonData"></mifosx-view-guarantors>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { BreachConfigComponent } from '../working-capital/loan-account-actions/b
import { WorkingCapitalChargeOffComponent } from '../working-capital/loan-account-actions/charge-off/charge-off.component';
import { WorkingCapitalWriteOffComponent } from '../working-capital/loan-account-actions/write-off/write-off.component';
import { WorkingCapitalUndoWriteOffComponent } from '../working-capital/loan-account-actions/undo-write-off/undo-write-off.component';
import { WorkingCapitalRecoveryPaymentComponent } from '../working-capital/loan-account-actions/recovery-payment/recovery-payment.component';
import { LoanProductService } from 'app/products/loan-products/services/loan-product.service';

/**
Expand Down Expand Up @@ -96,7 +97,8 @@ import { LoanProductService } from 'app/products/loan-products/services/loan-pro
BreachConfigComponent,
WorkingCapitalChargeOffComponent,
WorkingCapitalWriteOffComponent,
WorkingCapitalUndoWriteOffComponent
WorkingCapitalUndoWriteOffComponent,
WorkingCapitalRecoveryPaymentComponent
],
changeDetection: ChangeDetectionStrategy.OnPush
})
Expand Down
60 changes: 44 additions & 16 deletions src/app/loans/loans-view/loan-accounts-button-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,28 @@

import { OptionData } from 'app/shared/models/option-data.model';

/**
* One entry of the account actions menu.
*
* `disabled` and `disabledTooltip` let an action stay visible while being
* blocked by a business rule, so the user reads why instead of discovering it
* through a rejected request.
*/
export interface LoansAccountButton {
name: string;
icon?: string;
taskPermissionName?: string;
disabled?: boolean;
disabledTooltip?: string;
}

/** Loan/Working Capital Account Buttons Configuration */
export class LoansAccountButtonConfiguration {
optionArray: {
name: string;
taskPermissionName?: string;
}[];
optionArray: LoansAccountButton[];

optionPaymentArray: {
name: string;
taskPermissionName?: string;
}[];
optionPaymentArray: LoansAccountButton[];

buttonsArray: {
name: string;
icon: string;
taskPermissionName?: string;
}[];
buttonsArray: LoansAccountButton[];

private readonly isWorkingCapital: boolean;

Expand Down Expand Up @@ -253,8 +258,14 @@ export class LoansAccountButtonConfiguration {
];
break;
case 'Closed (written off)':
// Terminal state: only Undo Write-off remains available.
// The loan stays closed: a recovery payment records money collected
// after the write-off without reopening it.
this.buttonsArray = [
{
name: 'Recovery Payment',
icon: 'briefcase',
taskPermissionName: 'RECOVERYPAYMENT_WORKINGCAPITALLOAN'
},
{
name: 'Undo Write-off',
icon: 'undo',
Expand Down Expand Up @@ -431,14 +442,31 @@ export class LoansAccountButtonConfiguration {
}
}

addOption(option: { name: string; icon?: string; taskPermissionName?: string }) {
addOption(option: LoansAccountButton) {
this.optionArray.push(option);
}

addButton(option: { name: string; icon: string; taskPermissionName?: string }) {
addButton(option: LoansAccountButton) {
this.buttonsArray.push(option);
}

/**
* Blocks an action that is visible but not allowed right now.
*
* The button is kept in the menu on purpose: hiding it would leave the user
* wondering where the action went, while a disabled entry with a tooltip
* states the rule.
* @param name Action name as declared in the configuration
* @param tooltipKey Translation key explaining why it is blocked
*/
disableButton(name: string, tooltipKey: string) {
const button = this.buttonsArray?.find((item) => item.name === name);
if (button) {
button.disabled = true;
button.disabledTooltip = tooltipKey;
}
}

private isContractTermination(substatus: OptionData): boolean {
if (substatus == null) {
return false;
Expand Down
51 changes: 33 additions & 18 deletions src/app/loans/loans-view/loans-view.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,6 @@
</td>
</tr>
}
@if (loanProductService.isWorkingCapital && loanDetailsData.writtenOffOnDate) {
<tr>
<td>{{ 'labels.inputs.Written Off On' | translate }}:</td>
<td>
<span class="written-off-badge">{{ 'labels.status.Written Off' | translate }}</span>
<span class="m-l-10">{{ loanDetailsData.writtenOffOnDate | dateFormat }}</span>
@if (loanDetailsData.writeOffReason?.name) {
<span class="m-l-10">· {{ loanDetailsData.writeOffReason.name }}</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
Expand Down Expand Up @@ -256,14 +244,41 @@ <h3>{{ 'labels.heading.Account Overview' | translate }}</h3>
</div>
</mifosx-account-header>

@if (isWorkingCapitalWrittenOff) {
<div class="written-off-banner">
<span class="written-off-badge">{{ 'labels.status.Written Off' | translate }}</span>
<span class="banner-item">
{{ 'labels.inputs.Written Off On' | translate }}: {{ loanDetailsData.writtenOffOnDate | dateFormat }}
</span>
@if (loanDetailsData.writeOffReason?.name) {
<span class="banner-item">
{{ 'labels.inputs.Reason for Write-Off' | translate }}: {{ loanDetailsData.writeOffReason.name }}
</span>
}
</div>
}

<mat-menu #accountMenu="matMenu">
@for (item of buttonConfig.singleButtons; track item) {
<button mat-menu-item *mifosxHasPermission="item.taskPermissionName" (click)="loanAction(item.name)">
<mat-icon matListIcon>
<fa-icon icon="{{ item.icon }}" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.menus.' + item.name | translate }}</span>
</button>
<!-- The tooltip lives on the wrapper: a disabled button fires no mouse events. -->
<span
class="menu-item-wrapper"
*mifosxHasPermission="item.taskPermissionName"
[matTooltip]="item.disabledTooltip | translate"
[matTooltipDisabled]="!item.disabled"
>
<button mat-menu-item [disabled]="item.disabled" (click)="loanAction(item.name)">
Comment thread
alberto-art3ch marked this conversation as resolved.
<mat-icon matListIcon>
<fa-icon icon="{{ item.icon }}" size="sm"></fa-icon>
</mat-icon>
<span>{{ 'labels.menus.' + item.name | translate }}</span>
<!-- The tooltip above is mouse-only: a screen reader gets the same
reason from here, since a disabled item takes no hover. -->
@if (item.disabled) {
<span class="menu-item-reason">{{ item.disabledTooltip | translate }}</span>
}
</button>
</span>
}

@if (buttonConfig.optionsPayment.length) {
Expand Down
44 changes: 43 additions & 1 deletion src/app/loans/loans-view/loans-view.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
font-weight: 400;
}

// Written-off pill shown in the account header for Working Capital loans.
// Written-off pill shown in the write-off banner for Working Capital loans.
.written-off-badge {
display: inline-block;
padding: 2px 8px;
Expand All @@ -33,9 +33,51 @@
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #fff;
background-color: var(--md-sys-color-error, #b3261e);
}

// Banner marking a written-off account. A written-off loan is closed with a
// zero balance, exactly like a settled one, so the state gets its own strip
// under the header instead of a line lost inside the metadata table.
.written-off-banner {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
margin: 0 16px 8px;
padding: 10px 16px;
border-radius: 8px;
border-left: 4px solid var(--md-sys-color-error, #b3261e);
background-color: rgb(179 38 30 / 8%);
font-size: 14px;
Comment thread
alberto-art3ch marked this conversation as resolved.
}

.banner-item {
font-weight: 500;
}

// Wrapper carrying the tooltip of a blocked menu action. Needs to be a block so
// the menu item inside keeps the full width of the panel.
.menu-item-wrapper {
display: block;
}

// Reason a menu action is blocked, read by screen readers only: the tooltip on
// the wrapper needs a hover the disabled item never receives.
.menu-item-reason {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
white-space: nowrap;
clip: rect(0 0 0 0);
Comment thread
alberto-art3ch marked this conversation as resolved.
clip-path: inset(50%);
}

.datatable-scroll-x {
width: 100%;
overflow-x: auto;
Expand Down
31 changes: 31 additions & 0 deletions src/app/loans/loans-view/loans-view.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
WorkingCapitalMarkAsFraudDialogResult
} from './working-capital/loan-account-actions/mark-as-fraud-dialog/mark-as-fraud-dialog.component';
import { LoanStatus } from '../models/loan-status.model';
import { mapWorkingCapitalWriteOffBalance } from '../models/working-capital/working-capital-loan-account.model';
import { Currency } from 'app/shared/models/general.model';
import { SettingsService } from 'app/settings/settings.service';
import { DelinquencyPausePeriod } from '../models/loan-account.model';
Expand Down Expand Up @@ -144,6 +145,10 @@ export class LoansViewComponent extends LoanProductBaseComponent implements OnIn
}
this.loanDatatables = data.loanDatatables || [];
this.loanStatus = this.loanDetailsData.status;
// The action menu is rebuilt at the end of this block, so the status it
// reads has to come from the details that were just resolved: ngOnInit
// does not run again when the route reuses this component.
this.status = this.loanDetailsData.status?.value;
this.currency = this.loanDetailsData.currency;
if (this.loanProductService.isLoanProduct) {
this.loanDisplayArrearsDelinquency = data.loanArrearsDelinquencyConfig.value || 0;
Expand Down Expand Up @@ -475,6 +480,22 @@ export class LoansViewComponent extends LoanProductBaseComponent implements OnIn
}
}

// Recovery gating for a written-off Working Capital loan. Both rules read
// the same figures, so they are mapped once here.
if (this.loanProductService.isWorkingCapital && this.status === 'Closed (written off)') {
const writeOffBalance = mapWorkingCapitalWriteOffBalance(this.loanDetailsData.balance);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (writeOffBalance) {
if (writeOffBalance.writtenOffOutstanding <= 0) {
this.buttonConfig.disableButton('Recovery Payment', 'tooltips.Nothing left to recover');
}
// Undoing the write-off would restore the full balance while the money
// already recovered stays booked as income: the same money counted twice.
if (writeOffBalance.totalRecovered > 0) {
this.buttonConfig.disableButton('Undo Write-off', 'tooltips.Reverse the recovery payments first');
}
}
}

// Fraud flag for Working Capital loans. It sits outside the status branches
// above because the backend only restricts marking: the loan must be active
// to be flagged, but clearing the flag stays valid in every status, and
Expand All @@ -496,6 +517,16 @@ export class LoansViewComponent extends LoanProductBaseComponent implements OnIn
}
}

/**
* Whether the account is a written-off Working Capital loan.
*
* A written-off loan looks exactly like a settled one - closed, zero balance -
* so the header needs an explicit marker to tell them apart.
*/
get isWorkingCapitalWrittenOff(): boolean {
return this.loanProductService.isWorkingCapital && !!this.loanDetailsData?.writtenOffOnDate;
}

loanAction(actionName: string) {
switch (actionName) {
case 'Recover From Guarantor':
Expand Down
Loading
Loading