Merge remote-tracking branch 'nexora/uate' into merge-uate-into-dev
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

# Conflicts:
#	src/app/core/shell/shell.component.html
#	src/app/tasks/tasks-routing.module.ts
#	src/environments/.env.ts
This commit is contained in:
2026-07-23 17:15:52 +05:30
73 changed files with 8988 additions and 107 deletions
@@ -0,0 +1,171 @@
<!--
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="csa-container mat-typography">
<!-- Hero -->
<div class="csa-header">
<fa-icon icon="piggy-bank" class="csa-header__icon"></fa-icon>
<div>
<h2 class="csa-header__title">Modify Savings Account Features</h2>
<div class="csa-header__sub">Review account, manage nominees and digital features before submit.</div>
</div>
</div>
@if (pendingEditLoading) {
<div class="esf-loading">
<mat-progress-spinner diameter="20" mode="indeterminate"></mat-progress-spinner>
<span>Checking for pending edits...</span>
</div>
}
@if (!pendingEditLoading && hasPendingEdit) {
<div class="esf-pending-banner">
<mat-icon>hourglass_top</mat-icon>
<div class="esf-pending-banner__text">
<strong>An edit is already pending approval for this account.</strong>
<span>
Submitted by {{ pendingEdit.makerUsername || 'a maker' }} on {{ pendingEdit.createdOn | dateFormat }}. Status:
<span class="esf-pending-banner__status">{{ pendingEdit.status }}</span>
@if (pendingEdit.holdAmount) {
&middot; Hold placed: {{ pendingEdit.holdAmount }}
}
</span>
<span>You cannot submit another edit until this one is approved or rejected.</span>
</div>
</div>
}
@if (!pendingEditLoading) {
<!-- Wizard tabs -->
<mat-tab-group
class="csa-tabs"
animationDuration="200ms"
[preserveContent]="true"
[(selectedIndex)]="selectedTabIndex"
>
<!-- Tab 1: Product Classification + Details -->
<mat-tab>
<ng-template mat-tab-label>
<fa-icon icon="check-square" class="csa-tab__icon"></fa-icon>
Product
</ng-template>
<div class="csa-tab-body">
<mifosx-savings-account-details-step
[savingsAccountTemplate]="savingsAccountAndTemplate"
(savingsAccountProductTemplate)="setTemplate($event)"
[readOnly]="true"
>
</mifosx-savings-account-details-step>
<div class="csa-actions">
<span class="csa-actions__spacer"></span>
<button mat-raised-button color="primary" [disabled]="!savingsAccountProductTemplate" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 2: Currency -->
<mat-tab [disabled]="!savingsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="dollar-sign" class="csa-tab__icon"></fa-icon>
Currency
</ng-template>
<div class="csa-tab-body">
@if (savingsAccountProductTemplate) {
<mifosx-savings-account-currency-step
[savingsAccountProductTemplate]="savingsAccountProductTemplate"
[primaryClientId]="savingsAccountAndTemplate?.clientId ?? null"
[existingAccount]="savingsAccountAndTemplate"
></mifosx-savings-account-currency-step>
}
<div class="csa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="csa-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 3: Digital -->
<mat-tab [disabled]="!savingsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="mobile-alt" class="csa-tab__icon"></fa-icon>
Digital
</ng-template>
<div class="csa-tab-body">
@if (savingsAccountProductTemplate) {
<mifosx-savings-account-digital-step
[savingsAccountProductTemplate]="savingsAccountProductTemplate"
[existingAccount]="savingsAccountAndTemplate"
></mifosx-savings-account-digital-step>
}
<div class="csa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="csa-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 4: Preview / Submit -->
<mat-tab [disabled]="!savingsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="csa-tab__icon"></fa-icon>
{{ 'labels.inputs.PREVIEW' | translate }}
</ng-template>
<div class="csa-tab-body">
@if (savingsAccountProductTemplate) {
<mifosx-savings-account-preview-step
[savingsAccountProductTemplate]="savingsAccountProductTemplate"
[savingsAccountTemplate]="savingsAccountAndTemplate"
[savingsAccountTermsForm]="savingsAccountTermsForm"
[savingsAccount]="savingsAccount"
[savingsAccountDigital]="savingsAccountDigital"
[savingsAccountNominees]="savingsAccountCurrencyStep?.nominees ?? []"
[hideButtons]="true"
(submitEvent)="submit()"
>
</mifosx-savings-account-preview-step>
}
<div class="csa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="csa-actions__spacer"></span>
<button mat-stroked-button type="button" (click)="cancel()">Cancel</button>
<button mat-raised-button color="primary" type="button" [disabled]="!canSubmit" (click)="submit()">
@if (submitting) {
<mat-progress-spinner diameter="18" mode="indeterminate"></mat-progress-spinner>
} @else {
<mat-icon>send</mat-icon> Submit for Approval
}
</button>
</div>
</div>
</mat-tab>
</mat-tab-group>
}
</div>
@@ -0,0 +1,206 @@
/**
* 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;
$muted: #64748b;
$amber: #92400e;
$amber-bg: #fffbeb;
$amber-border: #fde68a;
.esf-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 20px 0;
justify-content: center;
font-size: 13px;
color: $muted;
&--sm {
padding: 10px 0;
justify-content: flex-start;
}
}
.esf-pending-banner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
background: $amber-bg;
border: 1px solid $amber-border;
border-radius: 8px;
margin-bottom: 16px;
color: $amber;
mat-icon {
flex: 0 0 auto;
}
&__text {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 12.5px;
strong {
font-size: 13px;
}
}
&__status {
font-weight: 700;
text-transform: uppercase;
}
}
/* ─── CSA Stepper Styles ─── */
$navy: #0d1b2a;
$blue: #1e88e5;
$blue-d: #1e40af;
$indigo: #4f46e5;
.csa-container {
max-width: 1180px;
margin: 0 auto;
padding: 20px 24px;
}
.csa-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
&__icon {
width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: linear-gradient(135deg, $blue 0%, $indigo 100%);
color: #fff;
font-size: 18px;
box-shadow: 0 2px 8px rgb(29 78 216 / 25%);
flex-shrink: 0;
}
&__title {
margin: 0;
font-size: 20px;
font-weight: 800;
color: $navy;
line-height: 1.1;
letter-spacing: 0.02em;
}
&__sub {
margin-top: 2px;
font-size: 12.5px;
color: #64748b;
}
}
.csa-tabs {
background: #fff;
border: 1px solid $border;
border-radius: 14px;
box-shadow: 0 1px 3px rgb(15 23 42 / 4%);
overflow: hidden;
::ng-deep {
.mat-mdc-tab-header {
background: #f8fafc;
border-bottom: 1px solid $border;
}
.mat-mdc-tab {
min-width: 140px;
height: 50px;
padding: 0 22px;
font-size: 13px !important;
font-weight: 600 !important;
letter-spacing: 0.01em;
color: #475569 !important;
opacity: 1 !important;
}
.mat-mdc-tab:hover:not(.mdc-tab--active, .mat-mdc-tab-disabled) {
background: rgb(30 64 175 / 5%);
color: $navy !important;
}
.mat-mdc-tab.mat-mdc-tab-active {
.mdc-tab__text-label {
color: $blue-d !important;
font-weight: 700 !important;
}
}
.mdc-tab-indicator__content--underline {
border-color: $blue-d !important;
border-top-width: 3px !important;
}
.mat-mdc-tab.mat-mdc-tab-disabled {
opacity: 0.45 !important;
cursor: not-allowed;
}
.mat-mdc-tab-body-wrapper {
min-height: 320px;
}
.mat-mdc-tab-body,
.mat-mdc-tab-body-content {
overflow: visible !important;
}
}
}
.csa-tab__icon {
margin-right: 8px;
font-size: 13px;
}
.csa-tab-body {
padding: 24px;
@media (width <= 768px) {
padding: 16px;
}
}
.csa-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f1f5f9;
&__spacer {
flex: 1;
}
button {
height: 40px !important;
border-radius: 10px !important;
font-weight: 600 !important;
padding: 0 18px !important;
}
}
.m-l-10 {
margin-left: 8px;
}
.m-r-10 {
margin-right: 8px;
}
@@ -0,0 +1,399 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/** Angular Imports */
import { Component, inject, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule, FormGroup } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { MatTabsModule } from '@angular/material/tabs';
import { SavingsAccountDetailsStepComponent } from '../savings-account-stepper/savings-account-details-step/savings-account-details-step.component';
import { SavingsAccountCurrencyStepComponent } from '../savings-account-stepper/savings-account-currency-step/savings-account-currency-step.component';
import { SavingsAccountDigitalStepComponent } from '../savings-account-stepper/savings-account-digital-step/savings-account-digital-step.component';
import { SavingsAccountPreviewStepComponent } from '../savings-account-stepper/savings-account-preview-step/savings-account-preview-step.component';
/** Custom Services */
import { SavingsService } from '../savings.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { SuccessDialogComponent } from 'app/shared/success-dialog/success-dialog.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Default state for every Digital & Account Feature toggle, mirrors
* savings-account-digital-step's DEFAULT_FACILITY_CONFIG. */
const DEFAULT_FACILITY_CONFIG: Record<string, boolean> = {
atmFacility: false,
atmWithdrawalFacility: false,
upiFacility: false,
posTransactionsFacility: false,
chequeBook: false,
impsFacility: false,
smsAlerts: false,
bbpsFacility: false,
insuranceCover: false,
bepgFacility: false,
neftFacility: false,
rtgsFacility: false,
interBankTotalCapFacility: false,
withdrawalFacility: false,
depositFacilityKyc: false,
virtualDebitCard: false
};
/** Toggle metadata used to render the feature grid — key must match a
* property of DEFAULT_FACILITY_CONFIG / the backend's accountFacilityConfig map. */
const FACILITY_TOGGLES: { key: string; label: string; icon: string }[] = [
{ key: 'atmFacility', label: 'ATM Facility', icon: 'fa-credit-card' },
{ key: 'atmWithdrawalFacility', label: 'ATM Withdrawal', icon: 'fa-wallet' },
{ key: 'upiFacility', label: 'UPI Facility', icon: 'fa-mobile-alt' },
{ key: 'posTransactionsFacility', label: 'POS Transactions', icon: 'fa-exchange-alt' },
{ key: 'chequeBook', label: 'Cheque Book', icon: 'fa-book' },
{ key: 'impsFacility', label: 'IMPS Facility', icon: 'fa-bolt' },
{ key: 'smsAlerts', label: 'SMS Alerts', icon: 'fa-sms' },
{ key: 'bbpsFacility', label: 'BBPS Facility', icon: 'fa-bell' },
{ key: 'insuranceCover', label: 'Insurance Cover', icon: 'fa-shield-alt' },
{ key: 'bepgFacility', label: 'BEPG Facility', icon: 'fa-plug' },
{ key: 'neftFacility', label: 'NEFT Facility', icon: 'fa-money-check-alt' },
{ key: 'rtgsFacility', label: 'RTGS Facility', icon: 'fa-money-check' },
{ key: 'interBankTotalCapFacility', label: 'Inter-Bank Total Cap', icon: 'fa-balance-scale' },
{ key: 'withdrawalFacility', label: 'Withdrawal Facility', icon: 'fa-arrow-right-arrow-left' },
{ key: 'depositFacilityKyc', label: 'Deposit Facility (KYC)', icon: 'fa-id-card' },
{ key: 'virtualDebitCard', label: 'Virtual Debit Card', icon: 'fa-credit-card' }
];
/**
* Edit Nominee & Features — maker-checker submission screen.
*
* A maker proposes changes to a savings account's Digital & Account Features toggles and/or its
* nominee here; nothing is applied immediately. The request is staged and a different user (checker)
* must approve it from the "Pending Savings Account Edits" screen before it takes effect.
*/
@Component({
selector: 'mifosx-edit-savings-account-features',
templateUrl: './edit-savings-account-features.component.html',
styleUrls: ['./edit-savings-account-features.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatIcon,
MatProgressSpinner,
MatTabsModule,
SavingsAccountDetailsStepComponent,
SavingsAccountCurrencyStepComponent,
SavingsAccountDigitalStepComponent,
SavingsAccountPreviewStepComponent
]
})
export class EditSavingsAccountFeaturesComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private savingsService = inject(SavingsService);
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
private dialog = inject(MatDialog);
@ViewChild(SavingsAccountDetailsStepComponent) savingsAccountDetailsStep: SavingsAccountDetailsStepComponent;
@ViewChild(SavingsAccountCurrencyStepComponent) savingsAccountCurrencyStep: SavingsAccountCurrencyStepComponent;
@ViewChild(SavingsAccountDigitalStepComponent) savingsAccountDigitalStep: SavingsAccountDigitalStepComponent;
originalNominees: any[] = [];
get savingsAccountTermsForm(): FormGroup {
return new FormGroup({});
}
get savingsAccount() {
return this.savingsAccountDetailsStep ? { ...this.savingsAccountDetailsStep.savingsAccountDetails } : null;
}
get savingsAccountDigital() {
return this.savingsAccountDigitalStep?.savingsAccountDigital ?? null;
}
readonly toggles = FACILITY_TOGGLES;
accountId: string;
savingsAccountAndTemplate: any;
clientId = '';
accountNo = '';
savingsAccountProductTemplate: any = null;
selectedTabIndex = 0;
readonly totalTabs = 4;
originalFacilityConfig: Record<string, boolean> = { ...DEFAULT_FACILITY_CONFIG };
/** Current nominees on the account, fetched read-only for reference. */
nominees: any[] = [];
nomineesLoading = false;
relationshipOptions: any[] = [];
/** Pending edit already submitted for this account, if any. */
pendingEdit: any = null;
pendingEditLoading = true;
submitting = false;
constructor() {
this.accountId = this.route.snapshot.params['savingAccountId'];
this.loadPendingEdit();
this.route.data.subscribe((data: { savingsAccountAndTemplate: any }) => {
const account = data.savingsAccountAndTemplate;
this.savingsAccountAndTemplate = account;
this.clientId = account?.clientId != null ? String(account.clientId) : '';
this.accountNo = account?.accountNo || '';
this.originalFacilityConfig = { ...DEFAULT_FACILITY_CONFIG, ...(account?.accountFacilityConfig || {}) };
if (this.clientId) {
this.loadNominees();
this.loadRelationshipTemplate();
}
});
}
private todayIso(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
private loadPendingEdit(): void {
this.pendingEditLoading = true;
this.savingsService.getPendingSavingsAccountEdit(this.accountId).subscribe({
next: (edit: any) => {
this.pendingEdit = edit && edit.id ? edit : null;
this.pendingEditLoading = false;
},
error: () => {
this.pendingEdit = null;
this.pendingEditLoading = false;
}
});
}
private loadNominees(): void {
this.nomineesLoading = true;
this.clientsService.getClientNominees(this.clientId, this.accountNo, 'SB').subscribe({
next: (data: any) => {
this.nominees = Array.isArray(data) ? data : [];
this.originalNominees = JSON.parse(JSON.stringify(this.nominees));
this.nomineesLoading = false;
},
error: () => {
this.nominees = [];
this.originalNominees = [];
this.nomineesLoading = false;
}
});
}
private loadRelationshipTemplate(): void {
this.clientsService.getClientNomineeTemplate(this.clientId).subscribe({
next: (template: any) => {
this.relationshipOptions = template?.relationshipOptions || [];
},
error: () => {
this.relationshipOptions = [];
}
});
}
/** Max share allowed for the mode currently being edited — excludes the nominee's own
* existing share from the running total when updating that same nominee. */
get hasPendingEdit(): boolean {
return !!this.pendingEdit && this.pendingEdit.status === 'PENDING';
}
get facilityConfig(): Record<string, boolean> {
return this.savingsAccountDigitalStep?.facilityConfig ?? this.originalFacilityConfig;
}
get facilityChanged(): boolean {
const config = this.facilityConfig;
return this.toggles.some((t) => config[t.key] !== this.originalFacilityConfig[t.key]);
}
get changedToggles(): any[] {
const list: any[] = [];
const config = this.facilityConfig;
this.toggles.forEach((t) => {
if (config[t.key] !== this.originalFacilityConfig[t.key]) {
list.push({
label: t.label,
from: this.originalFacilityConfig[t.key],
to: config[t.key]
});
}
});
return list;
}
get nomineeChanged(): boolean {
return this.buildNomineePayload() !== null;
}
get canSubmit(): boolean {
return (
!this.hasPendingEdit &&
!this.pendingEditLoading &&
!this.submitting &&
(this.facilityChanged || this.nomineeChanged)
);
}
setTemplate($event: any): void {
this.savingsAccountProductTemplate = $event;
}
next(): void {
if (this.selectedTabIndex < this.totalTabs - 1) {
this.selectedTabIndex += 1;
}
}
prev(): void {
if (this.selectedTabIndex > 0) {
this.selectedTabIndex -= 1;
}
}
getRelationshipName(relationshipId: any): string {
const opt = this.relationshipOptions.find((r: any) => r.id === relationshipId);
return opt?.name || '';
}
private buildFacilityDiff(): Record<string, boolean> | null {
const diff: Record<string, boolean> = {};
let changed = false;
const config = this.facilityConfig;
this.toggles.forEach((t) => {
if (config[t.key] !== this.originalFacilityConfig[t.key]) {
diff[t.key] = config[t.key];
changed = true;
}
});
return changed ? diff : null;
}
private buildNomineePayload(): any | null {
const original = this.originalNominees || [];
const current = this.savingsAccountCurrencyStep?.nominees ?? [];
// Find if a nominee has been added
const added = current.find(
(curr: any) => !original.some((orig: any) => orig.id === curr.id || orig.nomineeClientId === curr.nomineeClientId)
);
if (added) {
return {
operation: 'ADD',
nomineeId: null,
nomineeClientId: added.nomineeClientId,
relationshipId: added.relationshipId,
percentageShare: added.percentageShare,
isActive: added.isActive !== undefined ? !!added.isActive : true,
nomineeIsMinor: !!added.nomineeIsMinor,
guardianClientId: added.nomineeIsMinor ? added.guardianClientId : null,
guardianRelationshipId: added.nomineeIsMinor ? added.guardianRelationshipId : null,
effectiveDate: this.todayIso(),
remarks: added.remarks || null
};
}
// Find if a nominee has been deleted
const deleted = original.find(
(orig: any) => !current.some((curr: any) => curr.id === orig.id || curr.nomineeClientId === orig.nomineeClientId)
);
if (deleted) {
return {
operation: 'DELETE',
nomineeId: deleted.id
};
}
// Find if any nominee has been updated
for (const curr of current) {
const orig = original.find((o: any) => o.id === curr.id || o.nomineeClientId === curr.nomineeClientId);
if (orig) {
const relChanged = orig.relationshipId !== curr.relationshipId;
const shareChanged = +orig.percentageShare !== +curr.percentageShare;
const minorChanged = !!orig.nomineeIsMinor !== !!curr.nomineeIsMinor;
const guardianChanged =
orig.guardianClientId !== curr.guardianClientId ||
orig.guardianRelationshipId !== curr.guardianRelationshipId;
const activeChanged = !!orig.isActive !== (curr.isActive !== undefined ? !!curr.isActive : true);
const remarksChanged = (orig.remarks || '') !== (curr.remarks || '');
if (relChanged || shareChanged || minorChanged || guardianChanged || activeChanged || remarksChanged) {
return {
operation: 'UPDATE',
nomineeId: orig.id,
relationshipId: curr.relationshipId,
percentageShare: curr.percentageShare,
isActive: curr.isActive !== undefined ? !!curr.isActive : true,
nomineeIsMinor: !!curr.nomineeIsMinor,
guardianClientId: curr.nomineeIsMinor ? curr.guardianClientId : null,
guardianRelationshipId: curr.nomineeIsMinor ? curr.guardianRelationshipId : null,
effectiveDate: this.todayIso(),
remarks: curr.remarks || null
};
}
}
}
return null;
}
submit(): void {
if (!this.canSubmit) return;
const locale = this.settingsService.language.code;
const payload: any = { locale, dateFormat: 'yyyy-MM-dd' };
const facilityDiff = this.buildFacilityDiff();
if (facilityDiff) {
payload.accountFacilityConfig = facilityDiff;
}
const nomineePayload = this.buildNomineePayload();
if (nomineePayload) {
payload.nominee = nomineePayload;
}
this.submitting = true;
this.savingsService.submitSavingsAccountEdit(this.accountId, payload).subscribe({
next: () => {
this.submitting = false;
this.showSuccess();
},
error: () => {
this.submitting = false;
}
});
}
private showSuccess(): void {
const ref = this.dialog.open(SuccessDialogComponent, {
width: '420px',
maxWidth: '92vw',
autoFocus: false,
data: {
title: 'Submitted for Approval',
message: 'Your changes have been submitted and are awaiting checker approval.',
buttonLabel: 'Done'
}
});
ref.afterClosed().subscribe(() => this.cancel());
}
cancel(): void {
this.router.navigate(['../'], { relativeTo: this.route });
}
}
@@ -71,7 +71,7 @@
<div class="sad-grid sad-grid--classification">
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Account Type</mat-label>
<mat-select [(ngModel)]="selectedAccountType" [ngModelOptions]="{ standalone: true }">
<mat-select [(ngModel)]="selectedAccountType" [ngModelOptions]="{ standalone: true }" [disabled]="readOnly">
@for (opt of accountTypeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
@@ -80,7 +80,11 @@
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Customer Segment</mat-label>
<mat-select [(ngModel)]="selectedCustomerSegment" [ngModelOptions]="{ standalone: true }">
<mat-select
[(ngModel)]="selectedCustomerSegment"
[ngModelOptions]="{ standalone: true }"
[disabled]="readOnly"
>
@for (opt of customerSegmentOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
@@ -117,7 +121,7 @@
<mat-form-field appearance="outline" class="sad-field">
<mat-label>Share Linking Enabled</mat-label>
<mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }">
<mat-select [(ngModel)]="shareLinkingEnabled" [ngModelOptions]="{ standalone: true }" [disabled]="readOnly">
<mat-option [value]="true">Yes</mat-option>
<mat-option [value]="false">No</mat-option>
</mat-select>
@@ -132,6 +136,7 @@
introducerId ? { clientId: introducerId, displayName: introducerName, cifNumber: introducerCif } : null
"
(introducerChange)="onIntroducerChange($event)"
[disabled]="readOnly"
></mifosx-introducer-search>
}
}
@@ -223,7 +228,7 @@
color="primary"
type="button"
class="soa-add-btn"
[disabled]="totalJointSharePercentage >= 100"
[disabled]="totalJointSharePercentage >= 100 || readOnly"
(click)="openAddJointHolderDialog()"
>
<mat-icon>person_add</mat-icon> Add Holder
@@ -269,6 +274,7 @@
max="100"
[(ngModel)]="primaryHolderShare"
[ngModelOptions]="{ standalone: true }"
[disabled]="readOnly"
placeholder="0"
(input)="onPrimaryShareInput($event)"
(blur)="onPrimaryShareBlur($event)"
@@ -320,7 +326,8 @@
class="soa-sign-toggle"
[class.soa-sign-toggle--mandatory]="h.isMandatory"
[class.soa-sign-toggle--optional]="!h.isMandatory"
(click)="toggleJointHolderMandatory(h)"
[disabled]="readOnly"
(click)="!readOnly && toggleJointHolderMandatory(h)"
>
<mat-icon>{{ h.isMandatory ? 'lock' : 'lock_open' }}</mat-icon>
{{ h.isMandatory ? 'Mandatory' : 'Optional' }}
@@ -328,7 +335,13 @@
</td>
<td class="soa-td soa-td--center">Co-Holder</td>
<td class="soa-td soa-td--center">
<button mat-icon-button color="warn" type="button" (click)="removeJointHolder(i)">
<button
mat-icon-button
color="warn"
type="button"
[disabled]="readOnly"
(click)="removeJointHolder(i)"
>
<mat-icon>delete_outline</mat-icon>
</button>
</td>
@@ -410,7 +423,8 @@
type="button"
class="soa-mode-card"
[class.soa-mode-card--selected]="modeOfOperation === opt.value"
(click)="modeOfOperation = opt.value"
[disabled]="readOnly"
(click)="!readOnly && (modeOfOperation = opt.value)"
>
<mat-icon class="soa-mode-card__icon">{{ opt.icon }}</mat-icon>
<div class="soa-mode-card__code">{{ opt.label }}</div>
@@ -465,8 +479,9 @@
[max]="maxDate"
[(ngModel)]="minorDob"
[ngModelOptions]="{ standalone: true }"
[disabled]="readOnly"
/>
<mat-datepicker-toggle matSuffix [for]="minorDobPicker"></mat-datepicker-toggle>
<mat-datepicker-toggle matSuffix [for]="minorDobPicker" [disabled]="readOnly"></mat-datepicker-toggle>
<mat-datepicker #minorDobPicker></mat-datepicker>
@if (clientDob) {
<mat-hint>Auto-filled from client profile</mat-hint>
@@ -487,7 +502,11 @@
<mat-form-field appearance="outline" class="soa-field">
<mat-label>Guardian Relationship</mat-label>
<mat-select [(ngModel)]="guardianRelationship" [ngModelOptions]="{ standalone: true }">
<mat-select
[(ngModel)]="guardianRelationship"
[ngModelOptions]="{ standalone: true }"
[disabled]="readOnly"
>
@for (rel of guardianRelationshipOptions; track rel) {
<mat-option [value]="rel">{{ rel }}</mat-option>
}
@@ -503,6 +522,7 @@
<mifosx-introducer-search
[excludedClientIds]="primaryClientId ? [primaryClientId] : []"
(introducerChange)="onGuardianChange($event)"
[disabled]="readOnly"
></mifosx-introducer-search>
@if (guardianName) {
@@ -630,7 +650,8 @@
class="soa-cat-tile"
[class.soa-cat-tile--selected]="selectedSpecialCategory === opt.value"
[class.soa-cat-tile--male]="true"
(click)="selectedSpecialCategory = opt.value"
[disabled]="readOnly"
(click)="!readOnly && (selectedSpecialCategory = opt.value)"
>
<mat-icon class="soa-cat-tile__icon">{{ opt.icon }}</mat-icon>
<span class="soa-cat-tile__label">{{ opt.label }}</span>
@@ -680,7 +701,8 @@
class="soa-cat-tile"
[class.soa-cat-tile--selected]="selectedWeakerSection === opt.value"
[class.soa-cat-tile--female]="true"
(click)="selectedWeakerSection = opt.value"
[disabled]="readOnly"
(click)="!readOnly && (selectedWeakerSection = opt.value)"
>
<mat-icon class="soa-cat-tile__icon">{{ opt.icon }}</mat-icon>
<span class="soa-cat-tile__label">{{ opt.label }}</span>
@@ -53,6 +53,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
/** Savings Account Template */
@Input() savingsAccountTemplate: any;
@Input() readOnly = false;
minDate = new Date(2000, 0, 1);
maxDate = new Date();
@@ -219,6 +220,9 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
this.cdr.detectChanges();
});
}
if (this.readOnly) {
this.savingsAccountDetailsForm.disable();
}
}
/**
@@ -277,6 +281,7 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
}
isTileDisabled(value: string): boolean {
if (this.readOnly) return true;
if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18;
if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60;
return false;
@@ -122,6 +122,7 @@ export class SavingsAccountViewComponent implements OnInit {
'Add Charge': 'admin',
'Apply Annual Fees': 'admin',
'Modify Application': 'admin',
'Edit Nominee & Features': 'admin',
'Assign Staff': 'admin',
'Unassign Staff': 'admin',
Approve: 'admin',
@@ -156,6 +157,7 @@ export class SavingsAccountViewComponent implements OnInit {
'Add Charge': 'plus-circle',
'Apply Annual Fees': 'receipt',
'Modify Application': 'edit',
'Edit Nominee & Features': 'edit',
'Assign Staff': 'user-plus',
'Unassign Staff': 'user-slash',
Approve: 'check-circle',
@@ -311,6 +313,9 @@ export class SavingsAccountViewComponent implements OnInit {
case 'Modify Application':
this.router.navigate(['edit'], { relativeTo: this.route });
break;
case 'Edit Nominee & Features':
this.router.navigate(['edit-nominee-features'], { relativeTo: this.route });
break;
case 'Delete':
this.deleteSavingsAccount();
break;
@@ -161,6 +161,10 @@ export class SavingsButtonsConfiguration {
switch (status) {
case 'Active':
this.optionArray = [
{
name: 'Edit Nominee & Features',
taskPermissionName: 'SUBMITEDIT_SAVINGSACCOUNT'
},
{
name: 'Post Interest',
taskPermissionName: 'POSTINTEREST_SAVINGSACCOUNT'
+13
View File
@@ -27,6 +27,7 @@ import { ExportTransactionsComponent } from './savings-account-view/transactions
import { EditTransactionComponent } from './savings-account-view/transactions/edit-transaction/edit-transaction.component';
import { CreateGsimAccountComponent } from './gsim-account/create-gsim-account/create-gsim-account.component';
import { GsimAccountComponent } from './gsim-account/gsim-account.component';
import { EditSavingsAccountFeaturesComponent } from './edit-savings-account-features/edit-savings-account-features.component';
/** Custom Resolvers */
import { SavingsAccountViewResolver } from './common-resolvers/savings-account-view.resolver';
@@ -171,6 +172,18 @@ const routes: Routes = [
savingsAccountAndTemplate: SavingsAccountAndTemplateResolver
}
},
{
path: ':savingAccountId/edit-nominee-features',
data: {
title: 'Edit Nominee & Features',
breadcrumb: 'Edit Nominee & Features',
routeParamBreadcrumb: false
},
component: EditSavingsAccountFeaturesComponent,
resolve: {
savingsAccountAndTemplate: SavingsAccountAndTemplateResolver
}
},
{
path: ':savingAccountId/transactions/:id',
data: { title: 'Savings Account Transactions', breadcrumb: 'Transactions', routeParamBreadcrumb: false },
+3 -1
View File
@@ -63,6 +63,7 @@ import { SavingsTransactionDatatableTabComponent } from './savings-account-view/
import { GeneralTabComponent } from './savings-account-view/general-tab/general-tab.component';
import { SavingsActiveClientMembersComponent } from './savings-account-stepper/savings-active-client-members/savings-active-client-members.component';
import { PassbookTabComponent } from './savings-account-view/passbook-tab/passbook-tab.component';
import { EditSavingsAccountFeaturesComponent } from './edit-savings-account-features/edit-savings-account-features.component';
/**
* Savings Module
@@ -121,7 +122,8 @@ import { PassbookTabComponent } from './savings-account-view/passbook-tab/passbo
SavingsTransactionDatatableTabComponent,
GeneralTabComponent,
SavingsActiveClientMembersComponent,
PassbookTabComponent
PassbookTabComponent,
EditSavingsAccountFeaturesComponent
],
providers: []
})
+45
View File
@@ -381,4 +381,49 @@ export class SavingsService {
loadSavingsDocument(savingAccountId: any, data: any): Observable<any> {
return this.http.post(`/savings/${savingAccountId}/documents`, data);
}
/**
* Submits a maker-checker edit request (Nominee and/or Digital & Account Features change) for a
* savings account. The edit is held for checker approval before it is actually applied.
* @param {string} accountId Savings Account Id
* @param {any} data Edit payload — `accountFacilityConfig` and/or `nominee`, plus locale/dateFormat
* @returns {Observable<any>}
*/
submitSavingsAccountEdit(accountId: string, data: any): Observable<any> {
return this.http.post(`/savingsaccounts/${accountId}/edit`, data);
}
/**
* @param {string} accountId Savings Account Id
* @returns {Observable<any>} the pending edit for this account, if any.
*/
getPendingSavingsAccountEdit(accountId: string): Observable<any> {
return this.http.get(`/savingsaccounts/${accountId}/edit/pending`);
}
/**
* @returns {Observable<any>} all savings account edits currently awaiting checker approval.
*/
getAllPendingSavingsAccountEdits(): Observable<any> {
return this.http.get(`/savingsaccounts/edit/pending`);
}
/**
* Approves a pending savings account edit, releasing any hold and applying the proposed changes.
* @param {string} accountId Savings Account Id
* @returns {Observable<any>}
*/
approveSavingsAccountEdit(accountId: string): Observable<any> {
return this.http.post(`/savingsaccounts/${accountId}/edit/approve`, {});
}
/**
* Rejects a pending savings account edit, releasing any hold without applying the changes.
* @param {string} accountId Savings Account Id
* @param {string} rejectReason Optional reason for rejection
* @returns {Observable<any>}
*/
rejectSavingsAccountEdit(accountId: string, rejectReason?: string): Observable<any> {
return this.http.post(`/savingsaccounts/${accountId}/edit/reject`, { rejectReason: rejectReason || '' });
}
}