merge(release): merge feature/minor-update into release/share-account-module

Combines share-account-module UI changes with minor-update features.
Resolved .env.ts conflict keeping newer version (260707/8a1b7d5d).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-09 13:43:47 +05:30
co-authored by Claude Sonnet 4.6
64 changed files with 1490 additions and 180 deletions
+4
View File
@@ -89,4 +89,8 @@ export class AccountsService {
if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate); if (params.asOnDate) httpParams = httpParams.set('asOnDate', params.asOnDate);
return this.http.get('/recurringdepositaccounts', { params: httpParams }); return this.http.get('/recurringdepositaccounts', { params: httpParams });
} }
terminateAccount(accountId: string, data: any): Observable<any> {
return this.http.post(`/v1/accounts/${accountId}/terminate`, data);
}
} }
@@ -45,14 +45,21 @@
<input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" /> <input matInput type="date" [value]="settlementDate" (change)="settlementDate = $any($event.target).value" />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>Teller</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="supervisorTellerId" (selectionChange)="onSupervisorTellerSelect($event.value)"> <mat-label>Teller</mat-label>
@for (t of tellers; track t.id) { <input matInput type="text" [value]="tellers[0].name" readonly />
<mat-option [value]="t.id">{{ t.name }}</mat-option> </mat-form-field>
} } @else {
</mat-select> <mat-form-field appearance="outline" subscriptSizing="dynamic">
</mat-form-field> <mat-label>Teller</mat-label>
<mat-select [value]="supervisorTellerId" (selectionChange)="onSupervisorTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier</mat-label> <mat-label>Cashier</mat-label>
@@ -140,17 +147,24 @@
} }
</div> </div>
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>Teller / Vault (To) *</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)"> <mat-label>Teller / Vault (To) *</mat-label>
@for (t of tellers; track t.id) { <input matInput type="text" [value]="toParty" readonly />
<mat-option [value]="t.id">{{ t.name }}</mat-option> </mat-form-field>
} } @else {
@if (tellers.length === 0) { <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-option [value]="null" disabled>No tellers configured</mat-option> <mat-label>Teller / Vault (To) *</mat-label>
} <mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
</mat-select> @for (t of tellers; track t.id) {
</mat-form-field> <mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Settlement Date</mat-label> <mat-label>Settlement Date</mat-label>
@@ -97,6 +97,10 @@ export class CashSettlementComponent implements OnInit {
.pipe(catchError(() => of([] as any[]))) .pipe(catchError(() => of([] as any[])))
.subscribe((list: any[]) => { .subscribe((list: any[]) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
this.onSupervisorTellerSelect(this.tellers[0].id);
}
}); });
this.loadData(); this.loadData();
} }
@@ -17,17 +17,24 @@
</header> </header>
<div class="pg-toolbar pg-toolbar--mat"> <div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>From (Teller / Head)</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="fromTellerId" (selectionChange)="onTellerSelect($event.value)"> <mat-label>From (Teller / Head)</mat-label>
@for (t of tellers; track t.id) { <input matInput type="text" [value]="fromParty" readonly />
<mat-option [value]="t.id">{{ t.name }}</mat-option> </mat-form-field>
} } @else {
@if (tellers.length === 0) { <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-option [value]="null" disabled>No tellers configured</mat-option> <mat-label>From (Teller / Head)</mat-label>
} <mat-select [value]="fromTellerId" (selectionChange)="onTellerSelect($event.value)">
</mat-select> @for (t of tellers; track t.id) {
</mat-form-field> <mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>To (Cashier)</mat-label> <mat-label>To (Cashier)</mat-label>
@@ -95,6 +95,9 @@ export class CashTransferComponent implements OnInit {
.pipe(catchError(() => of([] as any[]))) .pipe(catchError(() => of([] as any[])))
.subscribe((list) => { .subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
}); });
} }
@@ -116,6 +119,9 @@ export class CashTransferComponent implements OnInit {
.subscribe((r: any) => { .subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName })); this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
this.loadingCashiers = false; this.loadingCashiers = false;
if (this.cashiers.length === 1) {
this.onCashierSelect(this.cashiers[0].id);
}
}); });
} }
@@ -20,15 +20,22 @@
<div class="pg-toolbar pg-toolbar--mat"> <div class="pg-toolbar pg-toolbar--mat">
<!-- Teller + Cashier filters — hidden for cashier role (auto-loads own position) --> <!-- Teller + Cashier filters — hidden for cashier role (auto-loads own position) -->
@if (!isCashierMode) { @if (!isCashierMode) {
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>Teller (optional)</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)"> <mat-label>Teller</mat-label>
<mat-option [value]="null">All Tellers</mat-option> <input matInput type="text" [value]="tellers[0].name" readonly />
@for (t of tellers; track t.id) { </mat-form-field>
<mat-option [value]="t.id">{{ t.name }}</mat-option> } @else {
} <mat-form-field appearance="outline" subscriptSizing="dynamic">
</mat-select> <mat-label>Teller (optional)</mat-label>
</mat-form-field> <mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
<mat-option [value]="null">All Tellers</mat-option>
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier (optional)</mat-label> <mat-label>Cashier (optional)</mat-label>
@@ -85,6 +85,9 @@ export class CashierPositionComponent implements OnInit {
.pipe(catchError(() => of([] as any[]))) .pipe(catchError(() => of([] as any[])))
.subscribe((list) => { .subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
}); });
} }
} }
@@ -137,6 +140,9 @@ export class CashierPositionComponent implements OnInit {
.pipe(catchError(() => of({ cashiers: [] }))) .pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => { .subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName })); this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
if (this.cashiers.length === 1) {
this.selectedCashierId = this.cashiers[0].id;
}
}); });
} }
@@ -17,17 +17,24 @@
</header> </header>
<div class="pg-toolbar pg-toolbar--mat"> <div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>Teller</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)"> <mat-label>Teller</mat-label>
@for (t of tellers; track t.id) { <input matInput type="text" [value]="tellers[0].name" readonly />
<mat-option [value]="t.id">{{ t.name }}</mat-option> </mat-form-field>
} } @else {
@if (tellers.length === 0) { <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-option [value]="null" disabled>No tellers configured</mat-option> <mat-label>Teller</mat-label>
} <mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
</mat-select> @for (t of tellers; track t.id) {
</mat-form-field> <mat-option [value]="t.id">{{ t.name }}</mat-option>
}
@if (tellers.length === 0) {
<mat-option [value]="null" disabled>No tellers configured</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>From (Cashier)</mat-label> <mat-label>From (Cashier)</mat-label>
@@ -102,6 +102,9 @@ export class InterCashierTransferComponent implements OnInit {
.pipe(catchError(() => of([] as any[]))) .pipe(catchError(() => of([] as any[])))
.subscribe((list) => { .subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
}); });
} }
@@ -20,14 +20,21 @@
<mat-tab label="New Reconciliation"> <mat-tab label="New Reconciliation">
<div class="pg-tab-body"> <div class="pg-tab-body">
<div class="pg-toolbar pg-toolbar--mat"> <div class="pg-toolbar pg-toolbar--mat">
<mat-form-field appearance="outline" subscriptSizing="dynamic"> @if (tellers.length === 1) {
<mat-label>Teller</mat-label> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)"> <mat-label>Teller</mat-label>
@for (t of tellers; track t.id) { <input matInput type="text" [value]="tellers[0].name" readonly />
<mat-option [value]="t.id">{{ t.name }}</mat-option> </mat-form-field>
} } @else {
</mat-select> <mat-form-field appearance="outline" subscriptSizing="dynamic">
</mat-form-field> <mat-label>Teller</mat-label>
<mat-select [value]="selectedTellerId" (selectionChange)="onTellerSelect($event.value)">
@for (t of tellers; track t.id) {
<mat-option [value]="t.id">{{ t.name }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Cashier</mat-label> <mat-label>Cashier</mat-label>
@@ -92,6 +92,9 @@ export class ReconciliationComponent implements OnInit {
.pipe(catchError(() => of([] as any[]))) .pipe(catchError(() => of([] as any[])))
.subscribe((list) => { .subscribe((list) => {
this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name })); this.tellers = (list || []).map((t: any) => ({ id: t.id, name: t.name }));
if (this.tellers.length === 1) {
this.onTellerSelect(this.tellers[0].id);
}
}); });
this.loadHistory(); this.loadHistory();
} }
@@ -108,6 +111,9 @@ export class ReconciliationComponent implements OnInit {
.pipe(catchError(() => of({ cashiers: [] }))) .pipe(catchError(() => of({ cashiers: [] })))
.subscribe((r: any) => { .subscribe((r: any) => {
this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName })); this.cashiers = (r?.cashiers || []).map((c: any) => ({ id: c.id, label: c.staffName }));
if (this.cashiers.length === 1) {
this.onCashierSelect(this.cashiers[0].id);
}
}); });
} }
-10
View File
@@ -168,13 +168,6 @@
> >
<fa-icon icon="money-bill-wave"></fa-icon> My Deposits <fa-icon icon="money-bill-wave"></fa-icon> My Deposits
</a> </a>
<a
class="cashier-nav__item"
[routerLink]="['/cashier/share-collection']"
routerLinkActive="cashier-nav__item--active"
>
<fa-icon icon="hand-holding-usd"></fa-icon> Share Collection
</a>
<a <a
class="cashier-nav__item" class="cashier-nav__item"
[routerLink]="['/cashier/cash-exchange']" [routerLink]="['/cashier/cash-exchange']"
@@ -915,9 +908,6 @@
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']"> <button mat-menu-item tabindex="0" [routerLink]="['/cashier/cash-history']">
<fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History <fa-icon icon="history" class="menu-item-icon"></fa-icon> Cash History
</button> </button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/share-collection']">
<fa-icon icon="hand-holding-usd" class="menu-item-icon"></fa-icon> Share Collection
</button>
<button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']"> <button mat-menu-item tabindex="0" [routerLink]="['/cashier/transaction-entry']">
<fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry <fa-icon icon="keyboard" class="menu-item-icon"></fa-icon> Transaction Entry
</button> </button>
@@ -155,12 +155,13 @@
<input matInput [value]="productTemplate.introducerRequired ? 'Yes' : 'No'" readonly /> <input matInput [value]="productTemplate.introducerRequired ? 'Yes' : 'No'" readonly />
</mat-form-field> </mat-form-field>
<div class="sad-field sad-info-row"> <mat-form-field appearance="outline" class="sad-field">
<span class="sad-info-label">Share Linking Enabled</span> <mat-label>Share Linking Enabled</mat-label>
<span class="sad-badge" [class.sad-badge--yes]="productTemplate.shareLinkingEnabled"> <mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }">
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }} <mat-option [value]="true">Yes</mat-option>
</span> <mat-option [value]="false">No</mat-option>
</div> </mat-select>
</mat-form-field>
</div> </div>
</div> </div>
@@ -67,6 +67,8 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
introducerCif: string | null = null; introducerCif: string | null = null;
private isEditMode = false; private isEditMode = false;
shareLinkingEnabled = false;
// ── Non-Individual (existing) ────────────────────────────────────────── // ── Non-Individual (existing) ──────────────────────────────────────────
isNonIndividualClient = false; isNonIndividualClient = false;
relatedClients: Array<{ relatedClients: Array<{
@@ -280,6 +282,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
this.currentAccountProductTemplate.emit(response); this.currentAccountProductTemplate.emit(response);
this.fieldOfficerData = response.fieldOfficerOptions; this.fieldOfficerData = response.fieldOfficerOptions;
this.currentAccountProductSelected = true; this.currentAccountProductSelected = true;
this.shareLinkingEnabled = !!response.shareLinkingEnabled;
this.currentAccountDetailsForm.patchValue({ this.currentAccountDetailsForm.patchValue({
balanceSwapOption: !!response.balanceSwapOption, balanceSwapOption: !!response.balanceSwapOption,
msme: !!response.msme, msme: !!response.msme,
@@ -382,6 +385,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
this.introducerName = ea.introducerName ?? null; this.introducerName = ea.introducerName ?? null;
this.introducerCif = ea.introducerCifNumber ?? null; this.introducerCif = ea.introducerCifNumber ?? null;
} }
this.shareLinkingEnabled = ea.shareLinkingEnabled !== undefined ? !!ea.shareLinkingEnabled : !!ea.isLinkingEnable;
this.currentAccountDetailsForm.patchValue({ this.currentAccountDetailsForm.patchValue({
balanceSwapOption: !!ea.balanceSwapOption, balanceSwapOption: !!ea.balanceSwapOption,
msme: !!ea.msme, msme: !!ea.msme,
@@ -664,6 +668,7 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
get currentAccountDetails() { get currentAccountDetails() {
const value = this.currentAccountDetailsForm.getRawValue(); const value = this.currentAccountDetailsForm.getRawValue();
value.shareLinkingEnabled = this.shareLinkingEnabled;
if (this.introducerId) { if (this.introducerId) {
value.introducerId = this.introducerId; value.introducerId = this.introducerId;
value.introducerName = this.introducerName; value.introducerName = this.introducerName;
@@ -131,9 +131,18 @@
<span class="preview-label">Share Linking Enabled</span> <span class="preview-label">Share Linking Enabled</span>
<span <span
class="preview-value preview-badge" class="preview-value preview-badge"
[class.preview-badge--yes]="currentAccountProductTemplate?.shareLinkingEnabled" [class.preview-badge--yes]="
currentAccount?.shareLinkingEnabled !== undefined
? currentAccount.shareLinkingEnabled
: currentAccountProductTemplate?.shareLinkingEnabled
"
> >
{{ currentAccountProductTemplate?.shareLinkingEnabled | yesNo }} {{
(currentAccount?.shareLinkingEnabled !== undefined
? currentAccount.shareLinkingEnabled
: currentAccountProductTemplate?.shareLinkingEnabled
) | yesNo
}}
</span> </span>
</div> </div>
</div> </div>
@@ -24,6 +24,9 @@ import { ClientsService } from 'app/clients/clients.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { Currency } from 'app/shared/models/general.model'; import { Currency } from 'app/shared/models/general.model';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/** Environment Configuration */ /** Environment Configuration */
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@@ -83,6 +86,9 @@ export class CurrentAccountViewComponent implements OnInit {
private currentAccountsService = inject(CurrentAccountsService); private currentAccountsService = inject(CurrentAccountsService);
private clientsService = inject(ClientsService); private clientsService = inject(ClientsService);
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dialog = inject(MatDialog); dialog = inject(MatDialog);
/** Current Account Data */ /** Current Account Data */
@@ -126,7 +132,8 @@ export class CurrentAccountViewComponent implements OnInit {
Close: 'danger', Close: 'danger',
Reject: 'danger', Reject: 'danger',
Delete: 'danger', Delete: 'danger',
'Withdrawn by Client': 'danger' 'Withdrawn by Client': 'danger',
Terminate: 'danger'
}; };
private readonly ACTION_ICON: Record<string, string> = { private readonly ACTION_ICON: Record<string, string> = {
@@ -157,7 +164,8 @@ export class CurrentAccountViewComponent implements OnInit {
Close: 'times-circle', Close: 'times-circle',
Reject: 'ban', Reject: 'ban',
Delete: 'trash', Delete: 'trash',
'Withdrawn by Client': 'user-slash' 'Withdrawn by Client': 'user-slash',
Terminate: 'times-circle'
}; };
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' { kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
@@ -309,6 +317,9 @@ export class CurrentAccountViewComponent implements OnInit {
case 'Delete': case 'Delete':
this.deleteCurrentAccount(); this.deleteCurrentAccount();
break; break;
case 'Terminate':
this.terminateCurrentAccount();
break;
case 'Calculate Interest': case 'Calculate Interest':
this.calculateInterest(); this.calculateInterest();
break; break;
@@ -370,6 +381,32 @@ export class CurrentAccountViewComponent implements OnInit {
}); });
} }
private terminateCurrentAccount() {
const terminateCurrentAccountDialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Terminate Account',
dialogContext: 'Are you sure you want to terminate this account?',
type: 'Strong'
}
});
terminateCurrentAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response && response.confirm) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const terminatedOnDate = this.dateUtils.formatDate(new Date(), dateFormat);
const data = {
accountType: 'CURRENT',
terminatedOnDate,
dateFormat,
locale
};
this.accountsService.terminateAccount(this.currentAccountData.id, data).subscribe(() => {
this.reload();
});
}
});
}
/** /**
* Calculates current account's interest * Calculates current account's interest
*/ */
@@ -194,6 +194,10 @@ export class CurrentAccountButtonsConfiguration {
{ {
name: 'Delete', name: 'Delete',
taskPermissionName: 'DELETE_CURRENTACCOUNT' taskPermissionName: 'DELETE_CURRENTACCOUNT'
},
{
name: 'Terminate',
taskPermissionName: 'DELETE_CURRENTACCOUNT'
} }
]; ];
break; break;
@@ -87,7 +87,7 @@ export class EditFixedDepositAccountComponent {
if (account?.depositPeriod) { if (account?.depositPeriod) {
this.selectedTenure = { this.selectedTenure = {
depositPeriod: account.depositPeriod, depositPeriod: account.depositPeriod,
depositPeriodFrequencyId: account.depositPeriodFrequencyType?.id ?? 2, depositPeriodFrequencyId: account.depositPeriodFrequency?.id ?? 2,
depositAmount: account.depositAmount, depositAmount: account.depositAmount,
isSeniorCitizen: !!account.isSeniorCitizen, isSeniorCitizen: !!account.isSeniorCitizen,
appliedRate: account.seniorCitizenInterestRate ?? undefined appliedRate: account.seniorCitizenInterestRate ?? undefined
@@ -50,6 +50,9 @@ export class FixedDepositAccountCurrencyStepComponent implements OnChanges {
} }
if (Array.isArray(ea.nominees) && ea.nominees.length > 0) { if (Array.isArray(ea.nominees) && ea.nominees.length > 0) {
this.nominees = [...ea.nominees]; this.nominees = [...ea.nominees];
this.nominationFacility = true;
} else {
this.nominationFacility = false;
} }
} }
if (changes['fixedDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) { if (changes['fixedDepositsAccountProductTemplate']?.currentValue && !this.existingAccountApplied) {
@@ -11,8 +11,8 @@ import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLinkActive, RouterLink, RouterOutlet } from '@angular/router'; import { ActivatedRoute, Router, RouterLinkActive, RouterLink, RouterOutlet } from '@angular/router';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
/** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component'; import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component'; import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component'; import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
@@ -23,7 +23,10 @@ import { FixedDepositsButtonsConfiguration } from './fixed-deposits-buttons.conf
/** Custom Services */ /** Custom Services */
import { FixedDepositsService } from '../fixed-deposits.service'; import { FixedDepositsService } from '../fixed-deposits.service';
import { SavingsService } from 'app/savings/savings.service'; import { SavingsService } from 'app/savings/savings.service';
import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model'; import { Currency } from 'app/shared/models/general.model';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common'; import { NgClass, CurrencyPipe } from '@angular/common';
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component'; import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
@@ -67,6 +70,9 @@ export class FixedDepositAccountViewComponent implements OnInit {
private router = inject(Router); private router = inject(Router);
private fixedDepositsService = inject(FixedDepositsService); private fixedDepositsService = inject(FixedDepositsService);
private savingsService = inject(SavingsService); private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dialog = inject(MatDialog); dialog = inject(MatDialog);
/** Fixed Deposits Account Data */ /** Fixed Deposits Account Data */
@@ -99,7 +105,8 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Apply Annual Fees': 'admin', 'Apply Annual Fees': 'admin',
Reject: 'danger', Reject: 'danger',
'Withdrawn by Client': 'danger', 'Withdrawn by Client': 'danger',
Delete: 'danger' Delete: 'danger',
Terminate: 'danger'
}; };
private readonly ACTION_ICON: Record<string, string> = { private readonly ACTION_ICON: Record<string, string> = {
@@ -121,7 +128,8 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Apply Annual Fees': 'receipt', 'Apply Annual Fees': 'receipt',
Reject: 'ban', Reject: 'ban',
'Withdrawn by Client': 'user-slash', 'Withdrawn by Client': 'user-slash',
Delete: 'trash' Delete: 'trash',
Terminate: 'times-circle'
}; };
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' { kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
@@ -237,6 +245,9 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Disable Withhold Tax': case 'Disable Withhold Tax':
this.disableWithHoldTax(); this.disableWithHoldTax();
break; break;
case 'Terminate':
this.terminateFixedDepositsAccount();
break;
} }
} }
@@ -256,6 +267,32 @@ export class FixedDepositAccountViewComponent implements OnInit {
}); });
} }
private terminateFixedDepositsAccount() {
const terminateFixedDepositAccountDialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Terminate Account',
dialogContext: 'Are you sure you want to terminate this account?',
type: 'Strong'
}
});
terminateFixedDepositAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response && response.confirm) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const terminatedOnDate = this.dateUtils.formatDate(new Date(), dateFormat);
const data = {
accountType: 'FD',
terminatedOnDate,
dateFormat,
locale
};
this.accountsService.terminateAccount(this.fixedDepositsAccountData.id, data).subscribe(() => {
this.reload();
});
}
});
}
/** /**
* Calculates fixed deposit account's interest * Calculates fixed deposit account's interest
*/ */
@@ -132,6 +132,9 @@ export class FixedDepositsButtonsConfiguration {
}, },
{ {
name: 'Delete' name: 'Delete'
},
{
name: 'Terminate'
} }
]; ];
break; break;
@@ -86,7 +86,7 @@ export class EditRecurringDepositAccountComponent {
// Pre-populate tenure from existing account // Pre-populate tenure from existing account
if (account?.depositPeriod) { if (account?.depositPeriod) {
this.selectedTenureValue = account.depositPeriod; this.selectedTenureValue = account.depositPeriod;
this.selectedTenureFrequencyId = account.depositPeriodFrequencyType?.id ?? 2; this.selectedTenureFrequencyId = account.depositPeriodFrequency?.id ?? 2;
} }
if (account?.mandatoryRecommendedDepositAmount) { if (account?.mandatoryRecommendedDepositAmount) {
this.selectedDepositAmount = account.mandatoryRecommendedDepositAmount; this.selectedDepositAmount = account.mandatoryRecommendedDepositAmount;
@@ -20,9 +20,13 @@ import { RecurringDepositsButtonsConfiguration } from './recurring-deposits-butt
/** Custom Dialogs */ /** Custom Dialogs */
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component'; import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { RecurringDepositConfirmationDialogComponent } from './custom-dialogs/recurring-deposit-confirmation-dialog/recurring-deposit-confirmation-dialog.component'; import { RecurringDepositConfirmationDialogComponent } from './custom-dialogs/recurring-deposit-confirmation-dialog/recurring-deposit-confirmation-dialog.component';
import { Currency } from 'app/shared/models/general.model'; import { Currency } from 'app/shared/models/general.model';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, DecimalPipe, CurrencyPipe } from '@angular/common'; import { NgClass, DecimalPipe, CurrencyPipe } from '@angular/common';
import { AccountNumberComponent } from '../../../shared/account-number/account-number.component'; import { AccountNumberComponent } from '../../../shared/account-number/account-number.component';
@@ -67,6 +71,9 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
private router = inject(Router); private router = inject(Router);
private recurringDepositsService = inject(RecurringDepositsService); private recurringDepositsService = inject(RecurringDepositsService);
private savingsService = inject(SavingsService); private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dialog = inject(MatDialog); dialog = inject(MatDialog);
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
@@ -103,7 +110,8 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Apply Annual Fees': 'admin', 'Apply Annual Fees': 'admin',
Reject: 'danger', Reject: 'danger',
'Withdrawn by Client': 'danger', 'Withdrawn by Client': 'danger',
Delete: 'danger' Delete: 'danger',
Terminate: 'danger'
}; };
private readonly ACTION_ICON: Record<string, string> = { private readonly ACTION_ICON: Record<string, string> = {
@@ -125,7 +133,8 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
'Apply Annual Fees': 'receipt', 'Apply Annual Fees': 'receipt',
Reject: 'ban', Reject: 'ban',
'Withdrawn by Client': 'user-slash', 'Withdrawn by Client': 'user-slash',
Delete: 'trash' Delete: 'trash',
Terminate: 'times-circle'
}; };
kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' { kindOf(name: string): 'transact' | 'block' | 'interest' | 'admin' | 'danger' {
@@ -287,6 +296,9 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
case 'Disable Withhold Tax': case 'Disable Withhold Tax':
this.disableWithHoldTax(); this.disableWithHoldTax();
break; break;
case 'Terminate':
this.terminateRecurringDepositsAccount();
break;
} }
} }
@@ -308,6 +320,32 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
}); });
} }
private terminateRecurringDepositsAccount() {
const terminateRecurringDepositAccountDialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Terminate Account',
dialogContext: 'Are you sure you want to terminate this account?',
type: 'Strong'
}
});
terminateRecurringDepositAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response && response.confirm) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const terminatedOnDate = this.dateUtils.formatDate(new Date(), dateFormat);
const data = {
accountType: 'RD',
terminatedOnDate,
dateFormat,
locale
};
this.accountsService.terminateAccount(this.recurringDepositsAccountData.id, data).subscribe(() => {
this.reload();
});
}
});
}
/** /**
* Calculates savings account's interest * Calculates savings account's interest
*/ */
@@ -163,6 +163,11 @@ export class RecurringDepositsButtonsConfiguration {
name: 'Delete', name: 'Delete',
icon: 'trash', icon: 'trash',
taskPermissionName: 'DELETE_RECURRINGDEPOSITACCOUNT' taskPermissionName: 'DELETE_RECURRINGDEPOSITACCOUNT'
},
{
name: 'Terminate',
icon: 'times-circle',
taskPermissionName: 'DELETE_RECURRINGDEPOSITACCOUNT'
} }
]; ];
break; break;
@@ -54,38 +54,6 @@
<td mat-cell *matCellDef="let cashier">{{ cashier.isFullDay | yesNo }}</td> <td mat-cell *matCellDef="let cashier">{{ cashier.isFullDay | yesNo }}</td>
</ng-container> </ng-container>
<ng-container matColumnDef="vaultActions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Vault Actions' | translate }}</th>
<td mat-cell *matCellDef="let cashier" (click)="$event.stopPropagation()">
<div class="vault-actions">
<a
class="vault-btn vault-btn--allocate"
matTooltip="{{ 'tooltips.Allocate Cash' | translate }}"
[routerLink]="[cashier.id, 'allocate']"
*mifosxHasPermission="'ALLOCATECASHIER_TELLER'"
>
<i class="fas fa-arrow-up"></i>
</a>
<a
class="vault-btn vault-btn--settle"
matTooltip="{{ 'tooltips.Settle Cash' | translate }}"
[routerLink]="[cashier.id, 'settle']"
*mifosxHasPermission="'SETTLECASHIER_TELLER'"
>
<i class="fas fa-arrow-down"></i>
</a>
<a
class="vault-btn vault-btn--view"
matTooltip="{{ 'tooltips.View Transactions' | translate }}"
[routerLink]="[cashier.id, 'transactions']"
*mifosxHasPermission="'ALLOCATECASHIER_TELLER'"
>
<i class="fas fa-eye"></i>
</a>
</div>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row" [routerLink]="[row.id]"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row" [routerLink]="[row.id]"></tr>
</table> </table>
@@ -67,8 +67,7 @@ export class CashiersComponent implements OnInit {
displayedColumns: string[] = [ displayedColumns: string[] = [
'period', 'period',
'staffName', 'staffName',
'isFullDay', 'isFullDay'
'vaultActions'
]; ];
/** Data source for cashiers table. */ /** Data source for cashiers table. */
dataSource: MatTableDataSource<any>; dataSource: MatTableDataSource<any>;
@@ -20,7 +20,7 @@
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label> <mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
<input matInput (keyup)="applyFilter($event.target.value)" /> <input matInput (keyup)="applyFilter($event.target.value)" />
</mat-form-field> </mat-form-field>
<div class="table-buttons"> <div class="table-buttons" *ngIf="!tellersData || tellersData.length === 0">
<button <button
mat-flat-button mat-flat-button
color="primary" color="primary"
@@ -78,7 +78,5 @@
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="table-row"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="table-row"></tr>
</table> </table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div> </div>
</div> </div>
@@ -8,7 +8,6 @@
/** Angular Imports */ /** Angular Imports */
import { Component, OnInit, ViewChild, inject } from '@angular/core'; import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort'; import { MatSort, MatSortHeader } from '@angular/material/sort';
import { import {
MatTableDataSource, MatTableDataSource,
@@ -55,7 +54,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow, MatHeaderRow,
MatRowDef, MatRowDef,
MatRow, MatRow,
MatPaginator,
DateFormatPipe DateFormatPipe
] ]
}) })
@@ -75,8 +73,6 @@ export class TellersComponent implements OnInit {
/** Data source for tellers table. */ /** Data source for tellers table. */
dataSource: MatTableDataSource<any>; dataSource: MatTableDataSource<any>;
/** Paginator for tellers table. */
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
/** Sorter for tellers table. */ /** Sorter for tellers table. */
@ViewChild(MatSort, { static: true }) sort: MatSort; @ViewChild(MatSort, { static: true }) sort: MatSort;
@@ -110,7 +106,6 @@ export class TellersComponent implements OnInit {
*/ */
setTellers() { setTellers() {
this.dataSource = new MatTableDataSource(this.tellersData); this.dataSource = new MatTableDataSource(this.tellersData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort; this.dataSource.sort = this.sort;
} }
} }
+1
View File
@@ -30,6 +30,7 @@ export class StatusLookupPipe implements PipeTransform {
'savingsAccountStatusType.activeInactive': 'status-active-overdue', 'savingsAccountStatusType.activeInactive': 'status-active-overdue',
'savingsAccountStatusType.activeDormant': 'status-active-overdue', 'savingsAccountStatusType.activeDormant': 'status-active-overdue',
'savingsAccountStatusType.matured': 'status-matured', 'savingsAccountStatusType.matured': 'status-matured',
'savingsAccountStatusType.terminated': 'status-deleted',
'shareAccountStatusType.submitted.and.pending.approval': 'status-pending', 'shareAccountStatusType.submitted.and.pending.approval': 'status-pending',
'shareAccountStatusType.approved': 'status-approved', 'shareAccountStatusType.approved': 'status-approved',
'shareAccountStatusType.active': 'status-active', 'shareAccountStatusType.active': 'status-active',
@@ -135,12 +135,19 @@
<button <button
type="button" type="button"
class="pl-action-btn pl-action-btn--edit" class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']" [class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
matTooltip="Edit this product" [matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_CURRENTPRODUCT'" *mifosxHasPermission="'UPDATE_CURRENTPRODUCT'"
> >
<fa-icon icon="edit"></fa-icon><span>Edit</span> <fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button> </button>
</td> </td>
</ng-container> </ng-container>
@@ -274,7 +274,8 @@ export class EditFixedDepositProductComponent {
...this.fixedDepositProduct, ...this.fixedDepositProduct,
charts: this.fixedDepositProductCharts, charts: this.fixedDepositProductCharts,
charges: (this.fixedDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })), charges: (this.fixedDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })),
locale: this.settingsService.language.code locale: this.settingsService.language.code,
dateFormat: this.settingsService.dateFormat
}); });
delete fixedDepositProduct.advancedAccountingRules; delete fixedDepositProduct.advancedAccountingRules;
this.productsService this.productsService
@@ -137,12 +137,19 @@
<button <button
type="button" type="button"
class="pl-action-btn pl-action-btn--edit" class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']" [class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
matTooltip="Edit this product" [matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_FIXEDDEPOSITPRODUCT'" *mifosxHasPermission="'UPDATE_FIXEDDEPOSITPRODUCT'"
> >
<fa-icon icon="edit"></fa-icon><span>Edit</span> <fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button> </button>
</td> </td>
</ng-container> </ng-container>
+100
View File
@@ -74,6 +74,26 @@ export class ProductsService {
return this.http.put(`/savingsproducts/${savingProductId}`, savingProduct); return this.http.put(`/savingsproducts/${savingProductId}`, savingProduct);
} }
/**
* Submitting a savings product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Savings Product Edits screen.
*/
getPendingSavingsProductEdits(): Observable<any> {
return this.http.get('/savingsproducts/pending');
}
getPendingSavingsProductEdit(savingsProductId: string): Observable<any> {
return this.http.get(`/savingsproducts/${savingsProductId}/pending`);
}
approveSavingsProductEdit(savingsProductId: string): Observable<any> {
return this.http.post(`/savingsproducts/${savingsProductId}/approve`, {});
}
rejectSavingsProductEdit(savingsProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/savingsproducts/${savingsProductId}/reject`, { rejectReason });
}
/** /**
* @returns {Observable<any>} Current products data * @returns {Observable<any>} Current products data
*/ */
@@ -102,6 +122,26 @@ export class ProductsService {
return this.http.delete(`/currentproducts/${currentProductId}`); return this.http.delete(`/currentproducts/${currentProductId}`);
} }
/**
* Submitting a current product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingCurrentProductEdits(): Observable<any> {
return this.http.get('/currentproducts/pending');
}
getPendingCurrentProductEdit(currentProductId: string): Observable<any> {
return this.http.get(`/currentproducts/${currentProductId}/pending`);
}
approveCurrentProductEdit(currentProductId: string): Observable<any> {
return this.http.post(`/currentproducts/${currentProductId}/approve`, {});
}
rejectCurrentProductEdit(currentProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/currentproducts/${currentProductId}/reject`, { rejectReason });
}
/** /**
* @returns {Observable<any>} Share products data * @returns {Observable<any>} Share products data
*/ */
@@ -126,6 +166,26 @@ export class ProductsService {
return this.http.put(`/products/share/${shareProductId}`, shareProduct); return this.http.put(`/products/share/${shareProductId}`, shareProduct);
} }
/**
* Submitting a share product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingShareProductEdits(): Observable<any> {
return this.http.get('/products/share/pending');
}
getPendingShareProductEdit(shareProductId: string): Observable<any> {
return this.http.get(`/products/share/${shareProductId}/pending`);
}
approveShareProductEdit(shareProductId: string): Observable<any> {
return this.http.post(`/products/share/${shareProductId}/approve`, {});
}
rejectShareProductEdit(shareProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/products/share/${shareProductId}/reject`, { rejectReason });
}
getDividends(shareProductId: string): Observable<any> { getDividends(shareProductId: string): Observable<any> {
return this.http.get(`/shareproduct/${shareProductId}/dividend`); return this.http.get(`/shareproduct/${shareProductId}/dividend`);
} }
@@ -251,6 +311,26 @@ export class ProductsService {
return this.http.put(`/fixeddepositproducts/${fixedDepositProductId}`, fixedDepositProduct); return this.http.put(`/fixeddepositproducts/${fixedDepositProductId}`, fixedDepositProduct);
} }
/**
* Submitting a fixed deposit product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingFixedDepositProductEdits(): Observable<any> {
return this.http.get('/fixeddepositproducts/pending');
}
getPendingFixedDepositProductEdit(fixedDepositProductId: string): Observable<any> {
return this.http.get(`/fixeddepositproducts/${fixedDepositProductId}/pending`);
}
approveFixedDepositProductEdit(fixedDepositProductId: string): Observable<any> {
return this.http.post(`/fixeddepositproducts/${fixedDepositProductId}/approve`, {});
}
rejectFixedDepositProductEdit(fixedDepositProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/fixeddepositproducts/${fixedDepositProductId}/reject`, { rejectReason });
}
/** /**
* @returns {Observable<any>} Tax Components data * @returns {Observable<any>} Tax Components data
*/ */
@@ -526,6 +606,26 @@ export class ProductsService {
return this.http.put(`/recurringdepositproducts/${recurringDepositProductId}`, recurringDepositProduct); return this.http.put(`/recurringdepositproducts/${recurringDepositProductId}`, recurringDepositProduct);
} }
/**
* Submitting a recurring deposit product update (above) puts it in the pending-approval temp table instead of
* applying it directly. These calls drive the Approvals -> Pending Product Edits screen.
*/
getPendingRecurringDepositProductEdits(): Observable<any> {
return this.http.get('/recurringdepositproducts/pending');
}
getPendingRecurringDepositProductEdit(recurringDepositProductId: string): Observable<any> {
return this.http.get(`/recurringdepositproducts/${recurringDepositProductId}/pending`);
}
approveRecurringDepositProductEdit(recurringDepositProductId: string): Observable<any> {
return this.http.post(`/recurringdepositproducts/${recurringDepositProductId}/approve`, {});
}
rejectRecurringDepositProductEdit(recurringDepositProductId: string, rejectReason: string): Observable<any> {
return this.http.post(`/recurringdepositproducts/${recurringDepositProductId}/reject`, { rejectReason });
}
/** Returns the active CBS base interest-rate version(s). */ /** Returns the active CBS base interest-rate version(s). */
getAllRdInterestRates(): Observable<any> { getAllRdInterestRates(): Observable<any> {
return this.getRdBaseInterestRates(); return this.getRdBaseInterestRates();
@@ -176,7 +176,11 @@ export class EditRecurringDepositProductComponent {
...this.recurringDepositProductSettingsStep.recurringDepositProductSettings, ...this.recurringDepositProductSettingsStep.recurringDepositProductSettings,
...this.recurringDepositProductChargesStep.recurringDepositProductCharges, ...this.recurringDepositProductChargesStep.recurringDepositProductCharges,
...this.recurringDepositProductAccountingStep.recurringDepositProductAccounting, ...this.recurringDepositProductAccountingStep.recurringDepositProductAccounting,
charts: this.recurringDepositProductCharts, // charts intentionally omitted here - the backend no longer requires a charts payload for RD
// updates once rdSlabs is present, and this getter's chart data comes from the shared CBS
// base-rate template (not this product's own chart), which caused spurious
// "chart.overlapping.from.and.end.dates" errors when a product's real chart had different
// dates. Still included in recurringDepositProductPreview below for the read-only display tab.
rdSlabs: this.activeBaseSlabs rdSlabs: this.activeBaseSlabs
}; };
} }
@@ -240,9 +244,9 @@ export class EditRecurringDepositProductComponent {
submit() { submit() {
const recurringDepositProduct: any = this.pruneEmptyValues({ const recurringDepositProduct: any = this.pruneEmptyValues({
...this.recurringDepositProduct, ...this.recurringDepositProduct,
charts: this.recurringDepositProductCharts,
charges: (this.recurringDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })), charges: (this.recurringDepositProduct.charges ?? []).map((charge: any) => ({ id: charge.id })),
locale: this.settingsService.language.code locale: this.settingsService.language.code,
dateFormat: this.settingsService.dateFormat
}); });
if (!recurringDepositProduct.description) { if (!recurringDepositProduct.description) {
recurringDepositProduct.description = ''; recurringDepositProduct.description = '';
@@ -91,7 +91,11 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
otherGlMappingsMap: this.recurringDepositProductsTemplate?.otherGlMappingsMap otherGlMappingsMap: this.recurringDepositProductsTemplate?.otherGlMappingsMap
}; };
const accountingMappingOptions = this.recurringDepositProductsTemplate?.accountingMappingOptions || {}; const accountingMappingOptions = this.recurringDepositProductsTemplate?.accountingMappingOptions || {};
const accountingRuleId = 2; // Always Cash // Defaults to Cash (2) for a brand-new product (no existing accountingRule yet), but preserves
// the product's own current accounting rule when editing - previously this was hardcoded to 2
// unconditionally, which silently forced every edited product onto Cash-based accounting (and
// its required GL mappings) even for products that were and should remain "None".
const accountingRuleId = this.recurringDepositProductsTemplate?.accountingRule?.id ?? 2;
this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || []; this.assetAccountOptions = accountingMappingOptions.assetAccountOptions || [];
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || []; this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
@@ -147,12 +147,19 @@
<button <button
type="button" type="button"
class="pl-action-btn pl-action-btn--edit" class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']" [class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
matTooltip="Edit this product" [matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_RECURRINGDEPOSITPRODUCT'" *mifosxHasPermission="'UPDATE_RECURRINGDEPOSITPRODUCT'"
> >
<fa-icon icon="edit"></fa-icon><span>Edit</span> <fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button> </button>
</td> </td>
</ng-container> </ng-container>
@@ -145,13 +145,19 @@
<button <button
type="button" type="button"
class="sp-action-btn sp-action-btn--edit" class="sp-action-btn sp-action-btn--edit"
[routerLink]="[p.id, 'edit']" [class.sp-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
matTooltip="Edit this product" [matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_SAVINGSPRODUCT'" *mifosxHasPermission="'UPDATE_SAVINGSPRODUCT'"
> >
<fa-icon icon="edit"></fa-icon> <fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>Edit</span> <span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button> </button>
</td> </td>
</ng-container> </ng-container>
@@ -481,6 +481,14 @@
border-color: var(--sp-amber); border-color: var(--sp-amber);
} }
} }
&--disabled,
&--disabled:hover {
background: #f1f5f9;
color: #94a3b8;
border-color: #e2e8f0;
cursor: not-allowed;
}
} }
/* No match strip */ /* No match strip */
@@ -135,12 +135,19 @@
<button <button
type="button" type="button"
class="pl-action-btn pl-action-btn--edit" class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']" [class.pl-action-btn--disabled]="p.isPendingApproval"
[disabled]="p.isPendingApproval"
[routerLink]="p.isPendingApproval ? null : [p.id, 'edit']"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
matTooltip="Edit this product" [matTooltip]="
p.isPendingApproval
? 'This product is locked because an edit is pending approval'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_SHAREPRODUCT'" *mifosxHasPermission="'UPDATE_SHAREPRODUCT'"
> >
<fa-icon icon="edit"></fa-icon><span>Edit</span> <fa-icon [icon]="p.isPendingApproval ? 'lock' : 'edit'"></fa-icon>
<span>{{ p.isPendingApproval ? 'Locked' : 'Edit' }}</span>
</button> </button>
</td> </td>
</ng-container> </ng-container>
@@ -28,6 +28,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** Custom Services */ /** Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service'; import { PopoverService } from '../../configuration-wizard/popover/popover.service';
import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service'; import { ConfigurationWizardService } from '../../configuration-wizard/configuration-wizard.service';
import { ProductsService } from 'app/products/products.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -60,6 +61,7 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
private router = inject(Router); private router = inject(Router);
private configurationWizardService = inject(ConfigurationWizardService); private configurationWizardService = inject(ConfigurationWizardService);
private popoverService = inject(PopoverService); private popoverService = inject(PopoverService);
private productsService = inject(ProductsService);
shareProductsData: any[] = []; shareProductsData: any[] = [];
displayedColumns: string[] = [ displayedColumns: string[] = [
@@ -101,6 +103,18 @@ export class ShareProductsComponent implements OnInit, AfterViewInit {
constructor() { constructor() {
this.route.data.subscribe((data: { shareProducts: any }) => { this.route.data.subscribe((data: { shareProducts: any }) => {
this.shareProductsData = data.shareProducts.pageItems; this.shareProductsData = data.shareProducts.pageItems;
// ShareProductData is immutable on the backend, so isPendingApproval isn't embedded per-row like the
// other product lists - cross-reference against the pending-edits list instead.
this.productsService.getPendingShareProductEdits().subscribe((pendingEdits: any[]) => {
const pendingProductIds = new Set((pendingEdits || []).map((e) => e.savingsProductId));
this.shareProductsData = this.shareProductsData.map((p) => ({
...p,
isPendingApproval: pendingProductIds.has(p.id)
}));
this.dataSource = new MatTableDataSource(this.shareProductsData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
});
}); });
} }
@@ -117,6 +117,7 @@
[savingsAccount]="savingsAccount" [savingsAccount]="savingsAccount"
[savingsAccountDigital]="savingsAccountDigital" [savingsAccountDigital]="savingsAccountDigital"
[savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []" [savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []"
[submitting]="submitting"
(submitEvent)="submit()" (submitEvent)="submit()"
> >
</mifosx-savings-account-preview-step> </mifosx-savings-account-preview-step>
@@ -64,6 +64,8 @@ export class CreateSavingsAccountComponent {
selectedTabIndex = 0; selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Digital, Preview. */ /** Total wizard tabs — Product, Currency, Digital, Preview. */
private readonly totalTabs = 4; private readonly totalTabs = 4;
/** Guards against duplicate submissions from a double-click while the create request is in flight. */
submitting = false;
@ViewChild(SavingsAccountDetailsStepComponent, { static: true }) @ViewChild(SavingsAccountDetailsStepComponent, { static: true })
savingsAccountDetailsStep: SavingsAccountDetailsStepComponent; savingsAccountDetailsStep: SavingsAccountDetailsStepComponent;
@@ -116,6 +118,9 @@ export class CreateSavingsAccountComponent {
} }
submit() { submit() {
if (this.submitting) {
return;
}
const detailsStep = this.savingsAccountDetailsStep; const detailsStep = this.savingsAccountDetailsStep;
if (detailsStep.selectedAccountStatus === 'JOINT' && detailsStep.totalJointSharePercentage !== 100) { if (detailsStep.selectedAccountStatus === 'JOINT' && detailsStep.totalJointSharePercentage !== 100) {
this.alertService.alert({ this.alertService.alert({
@@ -177,6 +182,7 @@ export class CreateSavingsAccountComponent {
})); }));
} }
this.submitting = true;
this.savingsService.createSavingsAccount(savingsAccount).subscribe({ this.savingsService.createSavingsAccount(savingsAccount).subscribe({
next: (response: any) => { next: (response: any) => {
this.router.navigate( this.router.navigate(
@@ -188,6 +194,7 @@ export class CreateSavingsAccountComponent {
); );
}, },
error: (error: any) => { error: (error: any) => {
this.submitting = false;
const errorCode = const errorCode =
error?.error?.errors?.[0]?.userMessageGlobalisationCode ?? error?.error?.userMessageGlobalisationCode; error?.error?.errors?.[0]?.userMessageGlobalisationCode ?? error?.error?.userMessageGlobalisationCode;
if (errorCode === 'error.msg.savings.account.duplicate.product.for.client') { if (errorCode === 'error.msg.savings.account.duplicate.product.for.client') {
@@ -1,4 +1,4 @@
<!-- <!--
Copyright since 2025 Mifos Initiative Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public This Source Code Form is subject to the terms of the Mozilla Public
@@ -115,12 +115,13 @@
<input matInput [value]="productTemplate.introducerRequired ? 'Yes' : 'No'" readonly /> <input matInput [value]="productTemplate.introducerRequired ? 'Yes' : 'No'" readonly />
</mat-form-field> </mat-form-field>
<div class="sad-field sad-info-row"> <mat-form-field appearance="outline" class="sad-field">
<span class="sad-info-label">Share Linking Enabled</span> <mat-label>Share Linking Enabled</mat-label>
<span class="sad-badge" [class.sad-badge--yes]="productTemplate.shareLinkingEnabled"> <mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }">
{{ productTemplate.shareLinkingEnabled ? 'Yes' : 'No' }} <mat-option [value]="true">Yes</mat-option>
</span> <mat-option [value]="false">No</mat-option>
</div> </mat-select>
</mat-form-field>
</div> </div>
</div> </div>
@@ -70,6 +70,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
@Output() savingsAccountProductTemplate = new EventEmitter(); @Output() savingsAccountProductTemplate = new EventEmitter();
productTemplate: any = null; productTemplate: any = null;
shareLinkingEnabled = false;
accountTypeOptions: string[] = []; accountTypeOptions: string[] = [];
customerSegmentOptions: string[] = []; customerSegmentOptions: string[] = [];
selectedAccountType: string | null = null; selectedAccountType: string | null = null;
@@ -333,6 +334,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
.getSavingsAccountTemplate(entityId, productId, this.savingsAccountTemplate.groupId ? true : false) .getSavingsAccountTemplate(entityId, productId, this.savingsAccountTemplate.groupId ? true : false)
.subscribe((response: any) => { .subscribe((response: any) => {
this.productTemplate = response; this.productTemplate = response;
this.shareLinkingEnabled = !!response.shareLinkingEnabled;
this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType); this.accountTypeOptions = this.extractOptions(response.accountTypeOptions, response.accountType);
this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment); this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment);
this.selectedAccountType = response.accountType ?? null; this.selectedAccountType = response.accountType ?? null;
@@ -432,6 +434,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
this.introducerName = ea.introducerName ?? null; this.introducerName = ea.introducerName ?? null;
this.introducerCif = ea.introducerCifNumber ?? null; this.introducerCif = ea.introducerCifNumber ?? null;
} }
if (ea.shareLinkingEnabled !== undefined) {
this.shareLinkingEnabled = !!ea.shareLinkingEnabled;
}
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
@@ -650,6 +655,8 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
value.minorDateOfBirth = this.minorDob ? this.dateUtils.formatDate(this.minorDob, 'dd MMMM yyyy') : null; value.minorDateOfBirth = this.minorDob ? this.dateUtils.formatDate(this.minorDob, 'dd MMMM yyyy') : null;
} }
value.shareLinkingEnabled = this.shareLinkingEnabled;
return value; return value;
} }
} }
@@ -116,9 +116,18 @@
<span class="preview-label">Share Linking Enabled</span> <span class="preview-label">Share Linking Enabled</span>
<span <span
class="preview-value preview-badge" class="preview-value preview-badge"
[class.preview-badge--yes]="savingsAccountProductTemplate?.shareLinkingEnabled" [class.preview-badge--yes]="
savingsAccount?.shareLinkingEnabled !== undefined
? savingsAccount.shareLinkingEnabled
: savingsAccountProductTemplate?.shareLinkingEnabled
"
> >
{{ savingsAccountProductTemplate?.shareLinkingEnabled | yesNo }} {{
(savingsAccount?.shareLinkingEnabled !== undefined
? savingsAccount.shareLinkingEnabled
: savingsAccountProductTemplate?.shareLinkingEnabled
) | yesNo
}}
</span> </span>
</div> </div>
</div> </div>
@@ -586,7 +595,13 @@
<button mat-stroked-button [routerLink]="['../..']"> <button mat-stroked-button [routerLink]="['../..']">
{{ 'labels.buttons.Cancel' | translate }} {{ 'labels.buttons.Cancel' | translate }}
</button> </button>
<button mat-raised-button color="primary" class="preview-submit-row__primary" (click)="submitEvent.emit()"> <button
mat-raised-button
color="primary"
class="preview-submit-row__primary"
[disabled]="submitting"
(click)="submitEvent.emit()"
>
<fa-icon icon="check" class="m-r-10"></fa-icon> <fa-icon icon="check" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Submit' | translate }} {{ 'labels.buttons.Submit' | translate }}
</button> </button>
@@ -94,6 +94,8 @@ export class SavingsAccountPreviewStepComponent implements OnChanges {
@Input() activeClientMembers?: any; @Input() activeClientMembers?: any;
/** Hide internal Cancel/Submit buttons when parent provides its own action bar */ /** Hide internal Cancel/Submit buttons when parent provides its own action bar */
@Input() hideButtons = false; @Input() hideButtons = false;
/** Disables the Submit button while a submission is in flight, preventing duplicate double-click requests */
@Input() submitting = false;
/** Table Data Source */ /** Table Data Source */
dataSource: any; dataSource: any;
@@ -24,6 +24,9 @@ import { SavingsService } from '../savings.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component'; import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { Currency } from 'app/shared/models/general.model'; import { Currency } from 'app/shared/models/general.model';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
/** Environment Configuration */ /** Environment Configuration */
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@@ -75,6 +78,9 @@ export class SavingsAccountViewComponent implements OnInit {
private router = inject(Router); private router = inject(Router);
private savingsService = inject(SavingsService); private savingsService = inject(SavingsService);
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
private accountsService = inject(AccountsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
dialog = inject(MatDialog); dialog = inject(MatDialog);
/** Savings Account Data */ /** Savings Account Data */
@@ -124,7 +130,8 @@ export class SavingsAccountViewComponent implements OnInit {
Close: 'danger', Close: 'danger',
Reject: 'danger', Reject: 'danger',
Delete: 'danger', Delete: 'danger',
'Withdrawn by Client': 'danger' 'Withdrawn by Client': 'danger',
Terminate: 'danger'
}; };
/** Lightweight icon fallback for actions whose buttonConfig doesn't /** Lightweight icon fallback for actions whose buttonConfig doesn't
@@ -157,7 +164,8 @@ export class SavingsAccountViewComponent implements OnInit {
Close: 'times-circle', Close: 'times-circle',
Reject: 'ban', Reject: 'ban',
Delete: 'trash', Delete: 'trash',
'Withdrawn by Client': 'user-slash' 'Withdrawn by Client': 'user-slash',
Terminate: 'times-circle'
}; };
/** Returns the colour kind for a given action name (used to tint the /** Returns the colour kind for a given action name (used to tint the
@@ -306,6 +314,9 @@ export class SavingsAccountViewComponent implements OnInit {
case 'Delete': case 'Delete':
this.deleteSavingsAccount(); this.deleteSavingsAccount();
break; break;
case 'Terminate':
this.terminateSavingsAccount();
break;
case 'Calculate Interest': case 'Calculate Interest':
this.calculateInterest(); this.calculateInterest();
break; break;
@@ -363,6 +374,35 @@ export class SavingsAccountViewComponent implements OnInit {
}); });
} }
/**
* Terminates Savings Account.
*/
private terminateSavingsAccount() {
const terminateSavingsAccountDialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Terminate Account',
dialogContext: 'Are you sure you want to terminate this account?',
type: 'Strong'
}
});
terminateSavingsAccountDialogRef.afterClosed().subscribe((response: any) => {
if (response && response.confirm) {
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
const terminatedOnDate = this.dateUtils.formatDate(new Date(), dateFormat);
const data = {
accountType: 'SAVINGS',
terminatedOnDate,
dateFormat,
locale
};
this.accountsService.terminateAccount(this.savingsAccountData.id, data).subscribe(() => {
this.reload();
});
}
});
}
/** /**
* Calculates savings account's interest * Calculates savings account's interest
*/ */
@@ -192,6 +192,10 @@ export class SavingsButtonsConfiguration {
{ {
name: 'Delete', name: 'Delete',
taskPermissionName: 'DELETE_SAVINGSACCOUNT' taskPermissionName: 'DELETE_SAVINGSACCOUNT'
},
{
name: 'Terminate',
taskPermissionName: 'DELETE_SAVINGSACCOUNT'
} }
]; ];
break; break;
@@ -103,6 +103,25 @@ export class CheckerInboxComponent implements OnInit {
'entity' 'entity'
]; ];
/**
* Entities whose pending edits are gated by our own m_savings_product_temp lock rather than the
* native command-source pipeline. They're excluded from Checker Inbox entirely - approving/rejecting
* them here would call the native /makercheckers/{id}?command=approve endpoint, which replays the
* original command handler directly and never touches our temp table, leaving its lock stuck forever.
* Action them from Approvals -> Pending Product Edits instead, where that's handled correctly.
*/
private readonly productEditEntities = [
'SAVINGSPRODUCT',
'CURRENTPRODUCT',
'SHAREPRODUCT',
'FIXEDDEPOSITPRODUCT',
'RECURRINGDEPOSITPRODUCT'
];
private excludeProductEdits(items: any[]): any[] {
return (items || []).filter((item: any) => !this.productEditEntities.includes(item?.entityName));
}
/** /**
* Retrieves the maker checker data from `resolve`. * Retrieves the maker checker data from `resolve`.
* @param {ActivatedRoute} route Activated Route. * @param {ActivatedRoute} route Activated Route.
@@ -115,11 +134,10 @@ export class CheckerInboxComponent implements OnInit {
*/ */
constructor() { constructor() {
this.route.data.subscribe((data: { makerCheckerResource: any; makerCheckerTemplate: any }) => { this.route.data.subscribe((data: { makerCheckerResource: any; makerCheckerTemplate: any }) => {
this.searchData = data.makerCheckerResource; const raw = data.makerCheckerResource;
if (this.searchData.length > 0) { this.searchData = this.excludeProductEdits(Array.isArray(raw) ? raw : (raw?.pageItems ?? []));
this.checkerData = true; this.checkerData = this.searchData.length > 0;
} this.makerCheckerTemplate = data.makerCheckerTemplate ?? {};
this.makerCheckerTemplate = data.makerCheckerTemplate;
this.dataSource = new MatTableDataSource(this.searchData); this.dataSource = new MatTableDataSource(this.searchData);
this.selection = new SelectionModel(true, []); this.selection = new SelectionModel(true, []);
}); });
@@ -157,7 +175,7 @@ export class CheckerInboxComponent implements OnInit {
makerDateTimeto: this.dateUtils.formatDate(this.makerCheckerSearchForm.value.makerDateTimeto, dateFormat) makerDateTimeto: this.dateUtils.formatDate(this.makerCheckerSearchForm.value.makerDateTimeto, dateFormat)
}; };
this.tasksService.getMakerCheckerData(makerCheckerSearchParams).subscribe((response: any) => { this.tasksService.getMakerCheckerData(makerCheckerSearchParams).subscribe((response: any) => {
this.searchData = response; this.searchData = this.excludeProductEdits(response);
if (this.searchData.length === 0) { if (this.searchData.length === 0) {
this.noSearchedData = true; this.noSearchedData = true;
} else { } else {
@@ -82,8 +82,10 @@ export class PendingAccountComponent {
constructor() { constructor() {
this.route.data.subscribe((data: { pendingAccountsData: any }) => { this.route.data.subscribe((data: { pendingAccountsData: any }) => {
// status 100 = submitted and pending approval only; exclude active/approved/etc. // status 100 = submitted and pending approval only; exclude terminated accounts
const items = (data.pendingAccountsData?.pageItems || []).filter((a: any) => a.status?.id === 100); const items = (data.pendingAccountsData?.pageItems || []).filter(
(a: any) => a.status?.id === 100 && !a.terminateTrue
);
this.showData = items.length > 0; this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items); this.dataSource = new MatTableDataSource(items);
this.selection = new SelectionModel(true, []); this.selection = new SelectionModel(true, []);
@@ -0,0 +1,108 @@
<!--
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="psp-tab-content">
@if (showData) {
<div class="psp-action-bar">
<mat-form-field appearance="outline" class="psp-search-field">
<mat-icon matPrefix>search</mat-icon>
<input #filterInput matInput placeholder="Search by product name" (keyup)="applyFilter(filterInput.value)" />
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="productType">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let edit">
<span class="status-badge status-badge--type">{{ edit.productType }}</span>
</td>
</ng-container>
<ng-container matColumnDef="savingsProductName">
<th mat-header-cell *matHeaderCellDef>Product</th>
<td mat-cell *matCellDef="let edit">{{ edit.savingsProductName }}</td>
</ng-container>
<ng-container matColumnDef="makerUsername">
<th mat-header-cell *matHeaderCellDef>Submitted By</th>
<td mat-cell *matCellDef="let edit">{{ edit.makerUsername }}</td>
</ng-container>
<ng-container matColumnDef="createdOn">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let edit">{{ formatDate(edit.createdOn) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let edit">
<span class="status-badge status-badge--pending">{{ edit.status }}</span>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let edit">
<button mat-stroked-button class="psp-btn psp-btn-view" (click)="viewDiff(edit)">
<fa-icon icon="eye" class="m-r-10"></fa-icon>Compare
</button>
<button mat-stroked-button class="psp-btn psp-btn-approve" (click)="approve(edit)">
<fa-icon icon="check" class="m-r-10"></fa-icon>Approve
</button>
<button mat-stroked-button class="psp-btn psp-btn-reject" (click)="reject(edit)">
<fa-icon icon="ban" class="m-r-10"></fa-icon>Reject
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
</div>
@if (selectedEdit) {
<div class="psp-diff-panel">
<div class="psp-diff-header">
<h3>Comparing "{{ selectedEdit.savingsProductName }}" &mdash; Original vs Proposed</h3>
<button mat-icon-button (click)="closeDiff()"><fa-icon icon="times"></fa-icon></button>
</div>
@if (diffRows.length === 0) {
<p class="psp-diff-loading">Loading comparison&hellip;</p>
} @else {
<table class="psp-diff-table">
<thead>
<tr>
<th>Field</th>
<th>Original</th>
<th>Proposed</th>
</tr>
</thead>
<tbody>
@for (row of diffRows; track row.label) {
<tr [class.psp-diff-row--changed]="row.changed">
<td class="psp-diff-label">{{ row.label }}</td>
<td class="psp-diff-original">{{ row.original }}</td>
<td class="psp-diff-proposed">{{ row.proposed }}</td>
</tr>
}
</tbody>
</table>
}
</div>
}
}
@if (!showData) {
<div class="empty-state">
<fa-icon icon="hourglass-half" class="empty-icon"></fa-icon>
<p class="empty-title">No pending savings product edits</p>
<p class="empty-text">There are no savings product edits awaiting approval at this time.</p>
</div>
}
</div>
@@ -0,0 +1,180 @@
/**
* 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/.
*/
$border: #e2e8f0;
$text-muted: #64748b;
.psp-tab-content {
padding: 16px 0;
}
.psp-action-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.psp-search-field {
flex: 1;
min-width: 200px;
max-width: 320px;
}
.psp-btn {
font-size: 11.5px;
font-weight: 600;
border-radius: 8px !important;
height: 32px;
padding: 0 10px;
margin-right: 6px;
fa-icon {
margin-right: 4px;
font-size: 11px;
}
}
.psp-btn-view {
border-color: #2563eb;
color: #2563eb;
&:hover {
background: #eff6ff;
}
}
.psp-btn-approve {
border-color: #16a34a;
color: #16a34a;
&:hover {
background: #f0fdf4;
}
}
.psp-btn-reject {
border-color: #dc2626;
color: #dc2626;
&:hover {
background: #fef2f2;
}
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
&--pending {
background: #fef9c3;
color: #854d0e;
}
&--type {
background: #e0e7ff;
color: #3730a3;
}
}
.psp-diff-panel {
margin-top: 20px;
border: 1px solid $border;
border-radius: 12px;
background: #fff;
padding: 16px;
}
.psp-diff-header {
display: flex;
align-items: center;
justify-content: space-between;
h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
}
.psp-diff-loading {
color: $text-muted;
font-size: 13px;
}
.psp-diff-table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
font-size: 12.5px;
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid $border;
}
th {
color: $text-muted;
font-weight: 600;
}
}
.psp-diff-row--changed {
background: #fffbeb;
.psp-diff-original {
color: #b91c1c;
text-decoration: line-through;
}
.psp-diff-proposed {
color: #15803d;
font-weight: 600;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
background: #fff;
border: 1px dashed $border;
border-radius: 14px;
text-align: center;
margin: 16px 0;
.empty-icon {
font-size: 42px;
color: #cbd5e1;
margin-bottom: 14px;
}
.empty-title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
color: #0d1b2a;
}
.empty-text {
margin: 0;
font-size: 12.5px;
color: $text-muted;
max-width: 380px;
line-height: 1.5;
}
}
@@ -0,0 +1,217 @@
/**
* 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 } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ProductsService } from 'app/products/products.service';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { FormDialogComponent } from 'app/shared/form-dialog/form-dialog.component';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-pending-savings-products',
templateUrl: './pending-savings-products.component.html',
styleUrls: ['./pending-savings-products.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon,
MatIconButton
]
})
export class PendingSavingsProductsComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
private productsService = inject(ProductsService);
dataSource: MatTableDataSource<any>;
showData = false;
displayedColumns: string[] = [
'productType',
'savingsProductName',
'makerUsername',
'createdOn',
'status',
'actions'
];
selectedEdit: any = null;
originalProduct: any = null;
diffRows: { label: string; original: any; proposed: any; changed: boolean }[] = [];
constructor() {
this.route.data.subscribe((data: { pendingSavingsProductsData: any }) => {
const items = data.pendingSavingsProductsData || [];
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
});
}
applyFilter(filterValue: string = '') {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
formatDate(date: any): string {
if (!date) return '';
const d = Array.isArray(date)
? new Date(date[0], date[1] - 1, date[2], date[3] || 0, date[4] || 0)
: new Date(date);
return isNaN(d.getTime()) ? String(date) : d.toLocaleString('en-IN');
}
private getOriginalProduct$(edit: any) {
switch (edit.productType) {
case 'CURRENT':
return this.productsService.getCurrentProduct(edit.savingsProductId);
case 'SHARE':
return this.productsService.getShareProduct(edit.savingsProductId);
case 'FD':
return this.productsService.getFixedDepositProduct(edit.savingsProductId);
case 'RD':
return this.productsService.getRecurringDepositProduct(edit.savingsProductId);
default:
return this.productsService.getSavingProduct(edit.savingsProductId);
}
}
private approveEdit$(edit: any) {
switch (edit.productType) {
case 'CURRENT':
return this.productsService.approveCurrentProductEdit(edit.savingsProductId);
case 'SHARE':
return this.productsService.approveShareProductEdit(edit.savingsProductId);
case 'FD':
return this.productsService.approveFixedDepositProductEdit(edit.savingsProductId);
case 'RD':
return this.productsService.approveRecurringDepositProductEdit(edit.savingsProductId);
default:
return this.productsService.approveSavingsProductEdit(edit.savingsProductId);
}
}
private rejectEdit$(edit: any, rejectReason: string) {
switch (edit.productType) {
case 'CURRENT':
return this.productsService.rejectCurrentProductEdit(edit.savingsProductId, rejectReason);
case 'SHARE':
return this.productsService.rejectShareProductEdit(edit.savingsProductId, rejectReason);
case 'FD':
return this.productsService.rejectFixedDepositProductEdit(edit.savingsProductId, rejectReason);
case 'RD':
return this.productsService.rejectRecurringDepositProductEdit(edit.savingsProductId, rejectReason);
default:
return this.productsService.rejectSavingsProductEdit(edit.savingsProductId, rejectReason);
}
}
viewDiff(edit: any): void {
this.selectedEdit = edit;
this.diffRows = [];
this.getOriginalProduct$(edit).subscribe((original: any) => {
this.originalProduct = original;
const proposed = edit.proposedChanges || {};
const keys = Object.keys(proposed);
this.diffRows = keys.map((key) => {
const originalValue = original ? original[key] : undefined;
const proposedValue = proposed[key];
return {
label: key,
original: originalValue,
proposed: proposedValue,
changed: JSON.stringify(originalValue) !== JSON.stringify(proposedValue)
};
});
});
}
closeDiff(): void {
this.selectedEdit = null;
this.originalProduct = null;
this.diffRows = [];
}
approve(edit: any): void {
const dialogRef = this.dialog.open(ConfirmationDialogComponent, {
data: {
heading: 'Approve Savings Product Edit',
dialogContext: `Apply the pending edit to "${edit.savingsProductName}"? This cannot be undone.`,
type: 'basic'
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.approveEdit$(edit).subscribe(() => this.reload());
}
});
}
reject(edit: any): void {
const formfields: FormfieldBase[] = [
new InputBase({
controlName: 'rejectReason',
label: 'Reason for rejection',
value: '',
type: 'text',
required: false
})
];
const dialogRef = this.dialog.open(FormDialogComponent, {
data: {
title: `Reject edit for "${edit.savingsProductName}"`,
layout: { addButtonText: 'Reject' },
formfields
}
});
dialogRef.afterClosed().subscribe((response: any) => {
if (response?.data) {
const rejectReason = response.data.value.rejectReason || '';
this.rejectEdit$(edit, rejectReason).subscribe(() => this.reload());
}
});
}
reload(): void {
this.closeDiff();
const url = this.router.url;
this.router
.navigateByUrl('/checker-inbox-and-tasks', { skipLocationChange: true })
.then(() => this.router.navigate([url]));
}
}
@@ -0,0 +1,75 @@
<!--
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="cv-tab-content">
@if (showData) {
<div class="cv-action-bar">
<mat-form-field appearance="outline" class="cv-search-field">
<mat-icon matPrefix>search</mat-icon>
<input matInput placeholder="Search accounts..." (keyup)="applyFilter($event.target['value'])" />
</mat-form-field>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" class="modern-table">
<ng-container matColumnDef="accountNo">
<th mat-header-cell *matHeaderCellDef>Account No</th>
<td mat-cell *matCellDef="let row" class="cell-mono">{{ row.accountNo }}</td>
</ng-container>
<ng-container matColumnDef="clientName">
<th mat-header-cell *matHeaderCellDef>Client Name</th>
<td mat-cell *matCellDef="let row">{{ row.clientName }}</td>
</ng-container>
<ng-container matColumnDef="productName">
<th mat-header-cell *matHeaderCellDef>Product</th>
<td mat-cell *matCellDef="let row">{{ row.savingsProductName || row.productName }}</td>
</ng-container>
<ng-container matColumnDef="accountType">
<th mat-header-cell *matHeaderCellDef>Type</th>
<td mat-cell *matCellDef="let row">{{ row.accountType }}</td>
</ng-container>
<ng-container matColumnDef="officeName">
<th mat-header-cell *matHeaderCellDef>Office</th>
<td mat-cell *matCellDef="let row">{{ row.officeName }}</td>
</ng-container>
<ng-container matColumnDef="submittedOnDate">
<th mat-header-cell *matHeaderCellDef>Submitted On</th>
<td mat-cell *matCellDef="let row">{{ formatDate(row.timeline?.submittedOnDate || row.submittedOnDate) }}</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let row">
<span class="status-badge status-badge--terminated">Terminated</span>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
(click)="navigateToAccount(row)"
class="table-row"
></tr>
</table>
</div>
}
@if (!showData) {
<div class="empty-state">
<i class="fas fa-times-circle empty-icon"></i>
<p class="empty-title">No terminated accounts</p>
<p class="empty-text">Accounts marked for termination will appear here.</p>
</div>
}
</div>
@@ -0,0 +1,17 @@
/**
* 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/.
*/
.status-badge--terminated {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
@@ -0,0 +1,106 @@
/**
* 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 } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { MatIcon } from '@angular/material/icon';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({
selector: 'mifosx-terminated-account',
templateUrl: './terminated-account.component.html',
styleUrls: ['./terminated-account.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatIcon
]
})
export class TerminatedAccountComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
dataSource: MatTableDataSource<any>;
showData = false;
displayedColumns: string[] = [
'accountNo',
'clientName',
'productName',
'accountType',
'officeName',
'submittedOnDate',
'status'
];
constructor() {
this.route.data.subscribe((data: { terminatedAccountsData: any }) => {
const items = data.terminatedAccountsData?.pageItems || [];
this.showData = items.length > 0;
this.dataSource = new MatTableDataSource(items);
});
}
navigateToAccount(account: any): void {
const clientId = account.clientId;
const accountId = account.id;
const depositTypeId = account.depositType?.id;
if (account.accountType === 'Current') {
this.router.navigate([`/clients/${clientId}/current-accounts/${accountId}`]);
} else if (depositTypeId === 200) {
this.router.navigate([`/clients/${clientId}/fixed-deposits-accounts/${accountId}`]);
} else if (depositTypeId === 300) {
this.router.navigate([`/clients/${clientId}/recurring-deposits-accounts/${accountId}`]);
} else {
this.router.navigate([`/clients/${clientId}/savings-accounts/${accountId}`]);
}
}
applyFilter(filterValue: string = '') {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
formatDate(date: any): string {
if (!date) return '';
if (Array.isArray(date)) {
return new Date(date[0], date[1] - 1, date[2]).toLocaleDateString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
}
const d = new Date(date);
return isNaN(d.getTime())
? date
: d.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
}
@@ -94,6 +94,18 @@
<fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon> <fa-icon icon="hourglass-half" class="ac-tab__ic"></fa-icon>
<span>Pending Account</span> <span>Pending Account</span>
</a> </a>
<a
mat-tab-link
class="ac-tab"
[routerLink]="['./pending-savings-products']"
routerLinkActive
#pendingSavingsProducts="routerLinkActive"
[active]="pendingSavingsProducts.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="piggy-bank" class="ac-tab__ic"></fa-icon>
<span>Pending Product Edits</span>
</a>
<a <a
mat-tab-link mat-tab-link
class="ac-tab" class="ac-tab"
@@ -106,6 +118,18 @@
<fa-icon icon="ban" class="ac-tab__ic"></fa-icon> <fa-icon icon="ban" class="ac-tab__ic"></fa-icon>
<span>Reject Account</span> <span>Reject Account</span>
</a> </a>
<a
mat-tab-link
class="ac-tab"
[routerLink]="['./terminated-account']"
routerLinkActive
#terminatedAccount="routerLinkActive"
[active]="terminatedAccount.isActive"
*mifosxHasPermission="'ALL_FUNCTIONS_READ'"
>
<fa-icon icon="times-circle" class="ac-tab__ic"></fa-icon>
<span>Terminated Account</span>
</a>
<a <a
mat-tab-link mat-tab-link
class="ac-tab" class="ac-tab"
@@ -0,0 +1,41 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ProductsService } from 'app/products/products.service';
/**
* Merges pending edits across all product types sharing the m_savings_product_temp backing table -
* Savings, Current Account, Share, Fixed Deposit, and Recurring Deposit products - into one list for
* the Pending Product Edits screen.
*/
@Injectable()
export class GetPendingSavingsProducts implements Resolve<any> {
private productsService = inject(ProductsService);
resolve(): Observable<any> {
return forkJoin({
savings: this.productsService.getPendingSavingsProductEdits().pipe(catchError(() => of([]))),
current: this.productsService.getPendingCurrentProductEdits().pipe(catchError(() => of([]))),
share: this.productsService.getPendingShareProductEdits().pipe(catchError(() => of([]))),
fd: this.productsService.getPendingFixedDepositProductEdits().pipe(catchError(() => of([]))),
rd: this.productsService.getPendingRecurringDepositProductEdits().pipe(catchError(() => of([])))
}).pipe(
map((results) => [
...(results.savings || []).map((e: any) => ({ ...e, productType: 'SAVINGS' })),
...(results.current || []).map((e: any) => ({ ...e, productType: 'CURRENT' })),
...(results.share || []).map((e: any) => ({ ...e, productType: 'SHARE' })),
...(results.fd || []).map((e: any) => ({ ...e, productType: 'FD' })),
...(results.rd || []).map((e: any) => ({ ...e, productType: 'RD' }))
])
);
}
}
@@ -0,0 +1,49 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { Resolve } from '@angular/router';
import { Observable, forkJoin, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { TasksService } from '../tasks.service';
@Injectable()
export class GetTerminatedAccounts implements Resolve<any> {
private tasksService = inject(TasksService);
resolve(): Observable<any> {
return forkJoin({
savings: this.tasksService.getTerminatedSavingsAccounts().pipe(catchError(() => of({ pageItems: [] }))),
current: this.tasksService.getTerminatedCurrentAccounts().pipe(catchError(() => of([]))),
offices: this.tasksService.getAllOffices().pipe(catchError(() => of([])))
}).pipe(
map((results) => {
const officeMap: Record<number, string> = {};
(results.offices || []).forEach((o: any) => {
officeMap[o.id] = o.name;
});
const savingsItems = (results.savings?.pageItems || []).map((a: any) => ({
...a,
officeName: officeMap[a.officeId] || a.officeName || '',
fieldOfficerName: a.fieldOfficerName || '',
accountType: a.depositType?.value || 'Savings'
}));
const currentItems = (Array.isArray(results.current) ? results.current : results.current?.pageItems || []).map(
(a: any) => ({ ...a, accountType: 'Current' })
);
return { pageItems: [
...savingsItems,
...currentItems
] };
})
);
}
}
+23 -1
View File
@@ -25,6 +25,8 @@ import { LoanDisbursalComponent } from './checker-inbox-and-tasks-tabs/loan-disb
import { RescheduleLoanComponent } from './checker-inbox-and-tasks-tabs/reschedule-loan/reschedule-loan.component'; import { RescheduleLoanComponent } from './checker-inbox-and-tasks-tabs/reschedule-loan/reschedule-loan.component';
import { PendingAccountComponent } from './checker-inbox-and-tasks-tabs/pending-account/pending-account.component'; import { PendingAccountComponent } from './checker-inbox-and-tasks-tabs/pending-account/pending-account.component';
import { RejectedAccountComponent } from './checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component'; import { RejectedAccountComponent } from './checker-inbox-and-tasks-tabs/rejected-account/rejected-account.component';
import { TerminatedAccountComponent } from './checker-inbox-and-tasks-tabs/terminated-account/terminated-account.component';
import { PendingSavingsProductsComponent } from './checker-inbox-and-tasks-tabs/pending-savings-products/pending-savings-products.component';
import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component'; import { ViewCheckerInboxComponent } from './view-checker-inbox/view-checker-inbox.component';
/** Custom Resolvers */ /** Custom Resolvers */
@@ -38,6 +40,8 @@ import { MakerCheckerTemplate } from './common-resolvers/makerCheckerTemplate.re
import { GetCheckerInboxDetailResolver } from './common-resolvers/getCheckerInboxDetail.resolver'; import { GetCheckerInboxDetailResolver } from './common-resolvers/getCheckerInboxDetail.resolver';
import { GetPendingAccounts } from './common-resolvers/getPendingAccounts.resolver'; import { GetPendingAccounts } from './common-resolvers/getPendingAccounts.resolver';
import { GetRejectedAccounts } from './common-resolvers/getRejectedAccounts.resolver'; import { GetRejectedAccounts } from './common-resolvers/getRejectedAccounts.resolver';
import { GetTerminatedAccounts } from './common-resolvers/getTerminatedAccounts.resolver';
import { GetPendingSavingsProducts } from './common-resolvers/getPendingSavingsProducts.resolver';
/** Tasks Routes */ /** Tasks Routes */
const routes: Routes = [ const routes: Routes = [
@@ -105,6 +109,22 @@ const routes: Routes = [
rejectedAccountsData: GetRejectedAccounts rejectedAccountsData: GetRejectedAccounts
} }
}, },
{
path: 'terminated-account',
component: TerminatedAccountComponent,
data: { title: 'Terminated Account' },
resolve: {
terminatedAccountsData: GetTerminatedAccounts
}
},
{
path: 'pending-savings-products',
component: PendingSavingsProductsComponent,
data: { title: 'Pending Savings Product Edits' },
resolve: {
pendingSavingsProductsData: GetPendingSavingsProducts
}
},
{ {
path: 'withdrawal-requests', path: 'withdrawal-requests',
component: WithdrawalRequestsComponent, component: WithdrawalRequestsComponent,
@@ -175,7 +195,9 @@ const routes: Routes = [
MakerCheckerTemplate, MakerCheckerTemplate,
GetCheckerInboxDetailResolver, GetCheckerInboxDetailResolver,
GetPendingAccounts, GetPendingAccounts,
GetRejectedAccounts GetRejectedAccounts,
GetTerminatedAccounts,
GetPendingSavingsProducts
] ]
}) })
export class TasksRoutingModule {} export class TasksRoutingModule {}
+10
View File
@@ -140,6 +140,16 @@ export class TasksService {
return this.http.get('/currentaccounts', { params: httpParams }); return this.http.get('/currentaccounts', { params: httpParams });
} }
getTerminatedSavingsAccounts(): Observable<any> {
const httpParams = new HttpParams().set('limit', '1000').set('status', 'terminated');
return this.http.get('/savingsaccounts', { params: httpParams });
}
getTerminatedCurrentAccounts(): Observable<any> {
const httpParams = new HttpParams().set('limit', '1000').set('status', 'terminated');
return this.http.get('/currentaccounts', { params: httpParams });
}
/** /**
* Approve a savings account * Approve a savings account
*/ */
@@ -6,17 +6,26 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/. file, You can obtain one at http://mozilla.org/MPL/2.0/.
--> -->
<div class="layout-row align-end gap-1percent layout-lt-md-column container m-b-20"> @if (isProductEditEntry) {
<button mat-raised-button color="success" (click)="approveChecker()"> <div class="container m-b-20">
<fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }} <div class="mat-elevation-z1" style="padding: 12px 16px; border-left: 4px solid #f59e0b; background: #fffbeb">
</button> This is a product edit awaiting approval. Approve or reject it from
<button mat-raised-button color="warn" (click)="deleteChecker()"> <strong>Approvals &gt; Pending Product Edits</strong> instead — actioning it here would not release its lock.
<fa-icon icon="trash" class="m-r-10"></fa-icon>{{ 'labels.buttons.Delete' | translate }} </div>
</button> </div>
<button mat-raised-button color="reject" (click)="rejectChecker()"> } @else {
<fa-icon icon="times" class="m-r-10"></fa-icon>{{ 'labels.buttons.Reject' | translate }} <div class="layout-row align-end gap-1percent layout-lt-md-column container m-b-20">
</button> <button mat-raised-button color="success" (click)="approveChecker()">
</div> <fa-icon icon="check" class="m-r-10"></fa-icon>{{ 'labels.buttons.Approve' | translate }}
</button>
<button mat-raised-button color="warn" (click)="deleteChecker()">
<fa-icon icon="trash" class="m-r-10"></fa-icon>{{ 'labels.buttons.Delete' | translate }}
</button>
<button mat-raised-button color="reject" (click)="rejectChecker()">
<fa-icon icon="times" class="m-r-10"></fa-icon>{{ 'labels.buttons.Reject' | translate }}
</button>
</div>
}
<div class="container"> <div class="container">
<mat-card> <mat-card>
@@ -50,6 +50,24 @@ export class ViewCheckerInboxComponent {
/** Checks if there is any object or not in jsondata */ /** Checks if there is any object or not in jsondata */
displayJSONData = false; displayJSONData = false;
/**
* These entities' pending edits are gated by our own m_savings_product_temp lock, not the native
* command-source pipeline. Approving/rejecting them here would replay the original command handler
* directly via /makercheckers/{id}?command=approve and never clear our temp table's lock. Action them
* from Approvals -> Pending Product Edits instead.
*/
private readonly productEditEntities = [
'SAVINGSPRODUCT',
'CURRENTPRODUCT',
'SHAREPRODUCT',
'FIXEDDEPOSITPRODUCT',
'RECURRINGDEPOSITPRODUCT'
];
get isProductEditEntry(): boolean {
return this.productEditEntities.includes(this.checkerInboxDetail?.entityName);
}
/** /**
* Retrieves the maker checker id data from `resolve`. * Retrieves the maker checker id data from `resolve`.
* @param {ActivatedRoute} route Activated Route. * @param {ActivatedRoute} route Activated Route.
+8
View File
@@ -540,6 +540,14 @@
border-color: var(--pl-amber); border-color: var(--pl-amber);
} }
} }
&--disabled,
&--disabled:hover {
background: #f1f5f9;
color: #94a3b8;
border-color: #e2e8f0;
cursor: not-allowed;
}
} }
/* No-match strip */ /* No-match strip */